mirror of
https://github.com/openai/codex.git
synced 2026-05-24 13:04:29 +00:00
## Why This PR builds on [#22610](https://github.com/openai/codex/pull/22610) and is the app-server side of the migration from mutable per-turn `SandboxPolicy` replacement toward selecting immutable permission profiles by id plus mutable runtime workspace roots. Once permission profiles can carry their own immutable `workspace_roots`, app-server no longer needs to mutate the selected `PermissionProfile` just to represent thread-specific filesystem context. The mutable part now lives on the thread as explicit `runtimeWorkspaceRoots`, while `:workspace_roots` remains symbolic until the sandbox is realized for a turn. ## What Changed - Replaced the v2 permission-selection wrapper surface with plain profile ids for `thread/start`, `thread/resume`, `thread/fork`, and `turn/start`. - Removed the API surface for profile modifications (`PermissionProfileSelectionParams`, `PermissionProfileModificationParams`, `ActivePermissionProfileModification`). - Added experimental `runtimeWorkspaceRoots` fields to the thread lifecycle and turn-start APIs. - Threaded runtime workspace roots through core session/thread snapshots, turn overrides, app-server request handling, and command execution permission resolution. - Kept session permission state symbolic so later runtime root updates and cwd-only implicit-root retargeting rebind `:workspace_roots` correctly. - Updated the embedded clients just enough to send and restore the new thread state. - Refreshed the generated schema/TypeScript artifacts and the app-server README to match the new contract. ## Verification Targeted coverage for this layer lives in: - `codex-rs/app-server-protocol/src/protocol/v2/tests.rs` - `codex-rs/app-server/tests/suite/v2/thread_start.rs` - `codex-rs/app-server/tests/suite/v2/thread_resume.rs` - `codex-rs/app-server/tests/suite/v2/turn_start.rs` - `codex-rs/core/src/session/tests.rs` The key regression checks exercise that: - `runtimeWorkspaceRoots` resolve against the effective cwd on thread start. - Profile-declared workspace roots are excluded from the runtime workspace roots returned by app-server. - A turn-level runtime workspace-root update persists onto the thread and is returned by `thread/resume`. - A named permission profile selected on one turn remains symbolic so a later runtime-root-only turn update changes the actual sandbox writes. - A cwd-only turn update retargets the implicit runtime cwd root while preserving additional runtime roots. - The protocol fixtures and generated client artifacts stay in sync with the string-based permission selection contract. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22611). * #22612 * __->__ #22611
995 lines
34 KiB
Rust
995 lines
34 KiB
Rust
use crate::realtime_conversation::handle_audio as handle_realtime_conversation_audio;
|
|
use crate::realtime_conversation::handle_close as handle_realtime_conversation_close;
|
|
use crate::realtime_conversation::handle_start as handle_realtime_conversation_start;
|
|
use crate::realtime_conversation::handle_text as handle_realtime_conversation_text;
|
|
use async_channel::Receiver;
|
|
use codex_otel::set_parent_from_w3c_trace_context;
|
|
use codex_protocol::protocol::Submission;
|
|
use tracing::Instrument;
|
|
use tracing::debug_span;
|
|
use tracing::info_span;
|
|
|
|
use crate::session::SteerInputError;
|
|
use crate::session::session::Session;
|
|
use crate::session::session::SessionSettingsUpdate;
|
|
|
|
use crate::config::Config;
|
|
use crate::realtime_context::REALTIME_TURN_TOKEN_BUDGET;
|
|
use crate::realtime_context::truncate_realtime_text_to_token_budget;
|
|
use crate::realtime_conversation::REALTIME_USER_TEXT_PREFIX;
|
|
use crate::realtime_conversation::prefix_realtime_v2_text;
|
|
use crate::review_prompts::resolve_review_request;
|
|
use crate::session::spawn_review_thread;
|
|
use crate::tasks::CompactTask;
|
|
use crate::tasks::UserShellCommandMode;
|
|
use crate::tasks::UserShellCommandTask;
|
|
use crate::tasks::execute_user_shell_command;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::ResponseInputItem;
|
|
use codex_protocol::protocol::CodexErrorInfo;
|
|
use codex_protocol::protocol::ErrorEvent;
|
|
use codex_protocol::protocol::Event;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::GuardianAssessmentEvent;
|
|
use codex_protocol::protocol::GuardianAssessmentStatus;
|
|
use codex_protocol::protocol::InterAgentCommunication;
|
|
use codex_protocol::protocol::McpServerRefreshConfig;
|
|
use codex_protocol::protocol::Op;
|
|
use codex_protocol::protocol::RealtimeConversationListVoicesResponseEvent;
|
|
use codex_protocol::protocol::RealtimeVoicesList;
|
|
use codex_protocol::protocol::ReviewDecision;
|
|
use codex_protocol::protocol::ReviewRequest;
|
|
use codex_protocol::protocol::RolloutItem;
|
|
use codex_protocol::protocol::ThreadMemoryMode;
|
|
use codex_protocol::protocol::ThreadRolledBackEvent;
|
|
use codex_protocol::protocol::TurnAbortReason;
|
|
use codex_protocol::protocol::WarningEvent;
|
|
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
|
use codex_protocol::request_user_input::RequestUserInputResponse;
|
|
|
|
use crate::context_manager::is_user_turn_boundary;
|
|
use codex_protocol::config_types::CollaborationMode;
|
|
use codex_protocol::config_types::ModeKind;
|
|
use codex_protocol::config_types::Settings;
|
|
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
|
use codex_protocol::items::UserMessageItem;
|
|
use codex_protocol::mcp::RequestId as ProtocolRequestId;
|
|
use codex_protocol::user_input::UserInput;
|
|
use codex_rmcp_client::ElicitationAction;
|
|
use codex_rmcp_client::ElicitationResponse;
|
|
use serde_json::Value;
|
|
use std::sync::Arc;
|
|
use tracing::debug;
|
|
use tracing::info;
|
|
use tracing::warn;
|
|
|
|
pub async fn interrupt(sess: &Arc<Session>) {
|
|
sess.interrupt_task().await;
|
|
}
|
|
|
|
pub async fn clean_background_terminals(sess: &Arc<Session>) {
|
|
sess.close_unified_exec_processes().await;
|
|
}
|
|
|
|
pub async fn realtime_conversation_list_voices(sess: &Session, sub_id: String) {
|
|
sess.send_event_raw(Event {
|
|
id: sub_id,
|
|
msg: EventMsg::RealtimeConversationListVoicesResponse(
|
|
RealtimeConversationListVoicesResponseEvent {
|
|
voices: RealtimeVoicesList::builtin(),
|
|
},
|
|
),
|
|
})
|
|
.await;
|
|
}
|
|
|
|
pub async fn override_turn_context(sess: &Session, sub_id: String, updates: SessionSettingsUpdate) {
|
|
if let Err(err) = sess.update_settings(updates).await {
|
|
sess.send_event_raw(Event {
|
|
id: sub_id,
|
|
msg: EventMsg::Error(ErrorEvent {
|
|
message: err.to_string(),
|
|
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
|
}),
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
|
|
pub async fn user_input_or_turn(sess: &Arc<Session>, sub_id: String, op: Op) {
|
|
user_input_or_turn_inner(
|
|
sess,
|
|
sub_id,
|
|
op,
|
|
/*mirror_user_text_to_realtime*/ Some(()),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub(super) async fn user_input_or_turn_inner(
|
|
sess: &Arc<Session>,
|
|
sub_id: String,
|
|
op: Op,
|
|
mirror_user_text_to_realtime: Option<()>,
|
|
) {
|
|
let (items, updates, responsesapi_client_metadata) = match op {
|
|
Op::UserTurn {
|
|
cwd,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_policy,
|
|
permission_profile,
|
|
model,
|
|
effort,
|
|
summary,
|
|
service_tier,
|
|
final_output_json_schema,
|
|
items,
|
|
collaboration_mode,
|
|
personality,
|
|
environments,
|
|
} => {
|
|
let collaboration_mode = collaboration_mode.or_else(|| {
|
|
Some(CollaborationMode {
|
|
mode: ModeKind::Default,
|
|
settings: Settings {
|
|
model: model.clone(),
|
|
reasoning_effort: effort,
|
|
developer_instructions: None,
|
|
},
|
|
})
|
|
});
|
|
(
|
|
items,
|
|
SessionSettingsUpdate {
|
|
cwd: Some(cwd),
|
|
approval_policy: Some(approval_policy),
|
|
approvals_reviewer,
|
|
sandbox_policy: Some(sandbox_policy),
|
|
workspace_roots: None,
|
|
profile_workspace_roots: None,
|
|
permission_profile,
|
|
active_permission_profile: None,
|
|
windows_sandbox_level: None,
|
|
collaboration_mode,
|
|
reasoning_summary: summary,
|
|
service_tier,
|
|
final_output_json_schema: Some(final_output_json_schema),
|
|
environments,
|
|
personality,
|
|
app_server_client_name: None,
|
|
app_server_client_version: None,
|
|
},
|
|
None,
|
|
)
|
|
}
|
|
Op::UserInputWithTurnContext {
|
|
cwd,
|
|
workspace_roots,
|
|
profile_workspace_roots,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_policy,
|
|
permission_profile,
|
|
active_permission_profile,
|
|
windows_sandbox_level,
|
|
model,
|
|
effort,
|
|
summary,
|
|
service_tier,
|
|
final_output_json_schema,
|
|
items,
|
|
responsesapi_client_metadata,
|
|
collaboration_mode,
|
|
personality,
|
|
environments,
|
|
} => {
|
|
let collaboration_mode = if let Some(collab_mode) = collaboration_mode {
|
|
Some(collab_mode)
|
|
} else {
|
|
let state = sess.state.lock().await;
|
|
Some(
|
|
state
|
|
.session_configuration
|
|
.collaboration_mode
|
|
.with_updates(model, effort, /*developer_instructions*/ None),
|
|
)
|
|
};
|
|
(
|
|
items,
|
|
SessionSettingsUpdate {
|
|
cwd,
|
|
workspace_roots,
|
|
profile_workspace_roots,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_policy,
|
|
permission_profile,
|
|
active_permission_profile,
|
|
windows_sandbox_level,
|
|
collaboration_mode,
|
|
reasoning_summary: summary,
|
|
service_tier,
|
|
final_output_json_schema: Some(final_output_json_schema),
|
|
environments,
|
|
personality,
|
|
app_server_client_name: None,
|
|
app_server_client_version: None,
|
|
},
|
|
responsesapi_client_metadata,
|
|
)
|
|
}
|
|
Op::UserInput {
|
|
items,
|
|
environments,
|
|
final_output_json_schema,
|
|
responsesapi_client_metadata,
|
|
} => (
|
|
items,
|
|
SessionSettingsUpdate {
|
|
final_output_json_schema: Some(final_output_json_schema),
|
|
environments,
|
|
..Default::default()
|
|
},
|
|
responsesapi_client_metadata,
|
|
),
|
|
_ => unreachable!(),
|
|
};
|
|
|
|
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else {
|
|
// new_turn_with_sub_id already emits the error event.
|
|
return;
|
|
};
|
|
sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref())
|
|
.await;
|
|
let accepted_items = match sess
|
|
.steer_input(
|
|
items.clone(),
|
|
/*expected_turn_id*/ None,
|
|
responsesapi_client_metadata.clone(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
current_context.session_telemetry.user_prompt(&items);
|
|
Some(items)
|
|
}
|
|
Err(SteerInputError::NoActiveTurn(items)) => {
|
|
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
|
|
current_context
|
|
.turn_metadata_state
|
|
.set_responsesapi_client_metadata(responsesapi_client_metadata);
|
|
}
|
|
current_context.session_telemetry.user_prompt(&items);
|
|
sess.refresh_mcp_servers_if_requested(
|
|
¤t_context,
|
|
Some(sess.mcp_elicitation_reviewer()),
|
|
)
|
|
.await;
|
|
let accepted_items = items.clone();
|
|
sess.spawn_task(
|
|
Arc::clone(¤t_context),
|
|
items,
|
|
crate::tasks::RegularTask::new(),
|
|
)
|
|
.await;
|
|
Some(accepted_items)
|
|
}
|
|
Err(err) => {
|
|
sess.send_event_raw(Event {
|
|
id: sub_id,
|
|
msg: EventMsg::Error(err.to_error_event()),
|
|
})
|
|
.await;
|
|
None
|
|
}
|
|
};
|
|
if let (Some(items), Some(())) = (accepted_items, mirror_user_text_to_realtime) {
|
|
self::mirror_user_text_to_realtime(sess, &items).await;
|
|
}
|
|
}
|
|
|
|
async fn mirror_user_text_to_realtime(sess: &Arc<Session>, items: &[UserInput]) {
|
|
let text = UserMessageItem::new(items).message();
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
let text = if sess.conversation.is_running_v2().await {
|
|
prefix_realtime_v2_text(text, REALTIME_USER_TEXT_PREFIX)
|
|
} else {
|
|
text
|
|
};
|
|
let text = truncate_realtime_text_to_token_budget(&text, REALTIME_TURN_TOKEN_BUDGET);
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
if sess.conversation.running_state().await.is_none() {
|
|
return;
|
|
}
|
|
if let Err(err) = sess.conversation.text_in(text).await {
|
|
debug!("failed to mirror user text to realtime conversation: {err}");
|
|
}
|
|
}
|
|
|
|
/// Records an inter-agent assistant envelope, then lets the shared pending-work scheduler
|
|
/// decide whether an idle session should start a regular turn.
|
|
pub async fn inter_agent_communication(
|
|
sess: &Arc<Session>,
|
|
sub_id: String,
|
|
communication: InterAgentCommunication,
|
|
) {
|
|
let trigger_turn = communication.trigger_turn;
|
|
sess.enqueue_mailbox_communication(communication);
|
|
if trigger_turn {
|
|
sess.maybe_start_turn_for_pending_work_with_sub_id(sub_id)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
pub async fn run_user_shell_command(sess: &Arc<Session>, sub_id: String, command: String) {
|
|
if let Some((turn_context, cancellation_token)) =
|
|
sess.active_turn_context_and_cancellation_token().await
|
|
{
|
|
let session = Arc::clone(sess);
|
|
tokio::spawn(async move {
|
|
execute_user_shell_command(
|
|
session,
|
|
turn_context,
|
|
command,
|
|
cancellation_token,
|
|
UserShellCommandMode::ActiveTurnAuxiliary,
|
|
)
|
|
.await;
|
|
});
|
|
return;
|
|
}
|
|
|
|
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
|
|
sess.spawn_task(
|
|
Arc::clone(&turn_context),
|
|
Vec::new(),
|
|
UserShellCommandTask::new(command),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn resolve_elicitation(
|
|
sess: &Arc<Session>,
|
|
server_name: String,
|
|
request_id: ProtocolRequestId,
|
|
decision: codex_protocol::approvals::ElicitationAction,
|
|
content: Option<Value>,
|
|
meta: Option<Value>,
|
|
) {
|
|
let action = match decision {
|
|
codex_protocol::approvals::ElicitationAction::Accept => ElicitationAction::Accept,
|
|
codex_protocol::approvals::ElicitationAction::Decline => ElicitationAction::Decline,
|
|
codex_protocol::approvals::ElicitationAction::Cancel => ElicitationAction::Cancel,
|
|
};
|
|
let content = match action {
|
|
// Preserve the legacy fallback for clients that only send an action.
|
|
ElicitationAction::Accept => Some(content.unwrap_or_else(|| serde_json::json!({}))),
|
|
ElicitationAction::Decline | ElicitationAction::Cancel => None,
|
|
};
|
|
let response = ElicitationResponse {
|
|
action,
|
|
content,
|
|
meta,
|
|
};
|
|
let request_id = match request_id {
|
|
ProtocolRequestId::String(value) => {
|
|
rmcp::model::NumberOrString::String(std::sync::Arc::from(value))
|
|
}
|
|
ProtocolRequestId::Integer(value) => rmcp::model::NumberOrString::Number(value),
|
|
};
|
|
if let Err(err) = sess
|
|
.resolve_elicitation(server_name, request_id, response)
|
|
.await
|
|
{
|
|
warn!(
|
|
error = %err,
|
|
"failed to resolve elicitation request in session"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Propagate a user's exec approval decision to the session.
|
|
/// Also optionally applies an execpolicy amendment.
|
|
pub async fn exec_approval(
|
|
sess: &Arc<Session>,
|
|
approval_id: String,
|
|
turn_id: Option<String>,
|
|
decision: ReviewDecision,
|
|
) {
|
|
let event_turn_id = turn_id.unwrap_or_else(|| approval_id.clone());
|
|
if let ReviewDecision::ApprovedExecpolicyAmendment {
|
|
proposed_execpolicy_amendment,
|
|
} = &decision
|
|
{
|
|
match sess
|
|
.persist_execpolicy_amendment(proposed_execpolicy_amendment)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
sess.record_execpolicy_amendment_message(
|
|
&event_turn_id,
|
|
proposed_execpolicy_amendment,
|
|
)
|
|
.await;
|
|
}
|
|
Err(err) => {
|
|
let message = format!("Failed to apply execpolicy amendment: {err}");
|
|
tracing::warn!("{message}");
|
|
let warning = EventMsg::Warning(WarningEvent { message });
|
|
sess.send_event_raw(Event {
|
|
id: event_turn_id.clone(),
|
|
msg: warning,
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
match decision {
|
|
ReviewDecision::Abort => {
|
|
sess.interrupt_task().await;
|
|
}
|
|
other => sess.notify_approval(&approval_id, other).await,
|
|
}
|
|
}
|
|
|
|
pub async fn patch_approval(sess: &Arc<Session>, id: String, decision: ReviewDecision) {
|
|
match decision {
|
|
ReviewDecision::Abort => {
|
|
sess.interrupt_task().await;
|
|
}
|
|
other => sess.notify_approval(&id, other).await,
|
|
}
|
|
}
|
|
|
|
pub async fn request_user_input_response(
|
|
sess: &Arc<Session>,
|
|
id: String,
|
|
response: RequestUserInputResponse,
|
|
) {
|
|
sess.notify_user_input_response(&id, response).await;
|
|
}
|
|
|
|
pub async fn request_permissions_response(
|
|
sess: &Arc<Session>,
|
|
id: String,
|
|
response: RequestPermissionsResponse,
|
|
) {
|
|
sess.notify_request_permissions_response(&id, response)
|
|
.await;
|
|
}
|
|
|
|
pub async fn dynamic_tool_response(sess: &Arc<Session>, id: String, response: DynamicToolResponse) {
|
|
sess.notify_dynamic_tool_response(&id, response).await;
|
|
}
|
|
|
|
pub async fn refresh_mcp_servers(sess: &Arc<Session>, refresh_config: McpServerRefreshConfig) {
|
|
let mut guard = sess.pending_mcp_server_refresh_config.lock().await;
|
|
*guard = Some(refresh_config);
|
|
}
|
|
|
|
pub async fn reload_user_config(sess: &Arc<Session>) {
|
|
sess.reload_user_config_layer().await;
|
|
}
|
|
|
|
pub async fn compact(sess: &Arc<Session>, sub_id: String) {
|
|
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
|
|
|
|
sess.spawn_task(
|
|
Arc::clone(&turn_context),
|
|
vec![UserInput::Text {
|
|
text: turn_context.compact_prompt().to_string(),
|
|
// Compaction prompt is synthesized; no UI element ranges to preserve.
|
|
text_elements: Vec::new(),
|
|
}],
|
|
CompactTask,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
pub async fn thread_rollback(sess: &Arc<Session>, sub_id: String, num_turns: u32) {
|
|
if num_turns == 0 {
|
|
sess.send_event_raw(Event {
|
|
id: sub_id,
|
|
msg: EventMsg::Error(ErrorEvent {
|
|
message: "num_turns must be >= 1".to_string(),
|
|
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
|
|
}),
|
|
})
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
let has_active_turn = { sess.active_turn.lock().await.is_some() };
|
|
if has_active_turn {
|
|
sess.send_event_raw(Event {
|
|
id: sub_id,
|
|
msg: EventMsg::Error(ErrorEvent {
|
|
message: "Cannot rollback while a turn is in progress.".to_string(),
|
|
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
|
|
}),
|
|
})
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
|
|
let live_thread = match sess.live_thread_for_persistence("rollback thread") {
|
|
Ok(live_thread) => live_thread,
|
|
Err(_) => {
|
|
sess.send_event_raw(Event {
|
|
id: turn_context.sub_id.clone(),
|
|
msg: EventMsg::Error(ErrorEvent {
|
|
message: "thread rollback requires persisted thread history".to_string(),
|
|
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
|
|
}),
|
|
})
|
|
.await;
|
|
return;
|
|
}
|
|
};
|
|
if let Err(err) = live_thread.flush().await {
|
|
sess.send_event_raw(Event {
|
|
id: turn_context.sub_id.clone(),
|
|
msg: EventMsg::Error(ErrorEvent {
|
|
message: format!("failed to flush thread persistence for rollback replay: {err}"),
|
|
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
|
|
}),
|
|
})
|
|
.await;
|
|
return;
|
|
}
|
|
|
|
let stored_history = match live_thread.load_history(/*include_archived*/ false).await {
|
|
Ok(history) => history,
|
|
Err(err) => {
|
|
sess.send_event_raw(Event {
|
|
id: turn_context.sub_id.clone(),
|
|
msg: EventMsg::Error(ErrorEvent {
|
|
message: format!("failed to load thread history for rollback replay: {err}"),
|
|
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
|
|
}),
|
|
})
|
|
.await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
let rollback_event = ThreadRolledBackEvent { num_turns };
|
|
let rollback_msg = EventMsg::ThreadRolledBack(rollback_event.clone());
|
|
let replay_items = stored_history
|
|
.items
|
|
.into_iter()
|
|
.chain(std::iter::once(RolloutItem::EventMsg(rollback_msg.clone())))
|
|
.collect::<Vec<_>>();
|
|
sess.apply_rollout_reconstruction(turn_context.as_ref(), replay_items.as_slice())
|
|
.await;
|
|
sess.recompute_token_usage(turn_context.as_ref()).await;
|
|
|
|
sess.persist_rollout_items(&[RolloutItem::EventMsg(rollback_msg.clone())])
|
|
.await;
|
|
if let Err(err) = sess.flush_rollout().await {
|
|
sess.send_event(
|
|
turn_context.as_ref(),
|
|
EventMsg::Warning(WarningEvent {
|
|
message: format!(
|
|
"Rolled the thread back, but failed to save the rollback marker. Codex will continue retrying. Error: {err}"
|
|
),
|
|
}),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
sess.deliver_event_raw(Event {
|
|
id: turn_context.sub_id.clone(),
|
|
msg: rollback_msg,
|
|
})
|
|
.await;
|
|
}
|
|
|
|
pub(super) async fn persist_thread_memory_mode_update(
|
|
sess: &Arc<Session>,
|
|
mode: ThreadMemoryMode,
|
|
) -> anyhow::Result<()> {
|
|
let live_thread = sess.live_thread_for_persistence("update thread memory mode")?;
|
|
live_thread.persist().await?;
|
|
live_thread.flush().await?;
|
|
live_thread
|
|
.update_memory_mode(mode, /*include_archived*/ false)
|
|
.await?;
|
|
live_thread.flush().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Persists thread-level memory mode metadata for the active session.
|
|
///
|
|
/// This does not involve the model and only affects whether the thread is
|
|
/// eligible for future memory generation.
|
|
pub async fn set_thread_memory_mode(sess: &Arc<Session>, sub_id: String, mode: ThreadMemoryMode) {
|
|
if let Err(err) = persist_thread_memory_mode_update(sess, mode).await {
|
|
warn!("Failed to persist thread memory mode update to rollout: {err}");
|
|
let event = Event {
|
|
id: sub_id,
|
|
msg: EventMsg::Error(ErrorEvent {
|
|
message: err.to_string(),
|
|
codex_error_info: Some(CodexErrorInfo::Other),
|
|
}),
|
|
};
|
|
sess.send_event_raw(event).await;
|
|
}
|
|
}
|
|
|
|
async fn shutdown_session_runtime(sess: &Arc<Session>) {
|
|
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
|
let _ = sess.conversation.shutdown().await;
|
|
sess.services
|
|
.unified_exec_manager
|
|
.terminate_all_processes()
|
|
.await;
|
|
let mcp_shutdown = {
|
|
let mut manager = sess.services.mcp_connection_manager.write().await;
|
|
manager.begin_shutdown()
|
|
};
|
|
mcp_shutdown.await;
|
|
sess.guardian_review_session.shutdown().await;
|
|
}
|
|
|
|
fn emit_thread_stop_lifecycle(sess: &Session) {
|
|
for contributor in sess.services.extensions.thread_lifecycle_contributors() {
|
|
contributor.on_thread_stop(codex_extension_api::ThreadStopInput {
|
|
session_store: &sess.services.session_extension_data,
|
|
thread_store: &sess.services.thread_extension_data,
|
|
});
|
|
}
|
|
}
|
|
|
|
pub async fn shutdown(sess: &Arc<Session>, sub_id: String) -> bool {
|
|
shutdown_session_runtime(sess).await;
|
|
info!("Shutting down Codex instance");
|
|
let history = sess.clone_history().await;
|
|
let turn_count = history
|
|
.raw_items()
|
|
.iter()
|
|
.filter(|item| is_user_turn_boundary(item))
|
|
.count();
|
|
sess.services.session_telemetry.counter(
|
|
"codex.conversation.turn.count",
|
|
i64::try_from(turn_count).unwrap_or(0),
|
|
&[],
|
|
);
|
|
|
|
emit_thread_stop_lifecycle(sess.as_ref());
|
|
|
|
// Gracefully flush and shutdown thread persistence on session end so tests
|
|
// that inspect durable state do not race with the background writer.
|
|
if let Some(live_thread) = sess.live_thread()
|
|
&& let Err(e) = live_thread.shutdown().await
|
|
{
|
|
warn!("failed to shutdown thread persistence: {e}");
|
|
let event = Event {
|
|
id: sub_id.clone(),
|
|
msg: EventMsg::Error(ErrorEvent {
|
|
message: "Failed to shutdown thread persistence".to_string(),
|
|
codex_error_info: Some(CodexErrorInfo::Other),
|
|
}),
|
|
};
|
|
sess.send_event_raw(event).await;
|
|
}
|
|
|
|
let event = Event {
|
|
id: sub_id,
|
|
msg: EventMsg::ShutdownComplete,
|
|
};
|
|
sess.services
|
|
.rollout_thread_trace
|
|
.record_protocol_event(&event.msg);
|
|
sess.deliver_event_raw(event).await;
|
|
sess.services
|
|
.rollout_thread_trace
|
|
.record_ended(codex_rollout_trace::RolloutStatus::Completed);
|
|
true
|
|
}
|
|
|
|
pub async fn review(
|
|
sess: &Arc<Session>,
|
|
config: &Arc<Config>,
|
|
sub_id: String,
|
|
review_request: ReviewRequest,
|
|
) {
|
|
let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await;
|
|
sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref())
|
|
.await;
|
|
sess.refresh_mcp_servers_if_requested(&turn_context, Some(sess.mcp_elicitation_reviewer()))
|
|
.await;
|
|
#[allow(deprecated)]
|
|
match resolve_review_request(review_request, &turn_context.cwd) {
|
|
Ok(resolved) => {
|
|
spawn_review_thread(
|
|
Arc::clone(sess),
|
|
Arc::clone(config),
|
|
turn_context.clone(),
|
|
sub_id,
|
|
resolved,
|
|
)
|
|
.await;
|
|
}
|
|
Err(err) => {
|
|
let event = Event {
|
|
id: sub_id,
|
|
msg: EventMsg::Error(ErrorEvent {
|
|
message: err.to_string(),
|
|
codex_error_info: Some(CodexErrorInfo::Other),
|
|
}),
|
|
};
|
|
sess.send_event(&turn_context, event.msg).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn submission_loop(
|
|
sess: Arc<Session>,
|
|
config: Arc<Config>,
|
|
rx_sub: Receiver<Submission>,
|
|
) {
|
|
// To break out of this loop, send Op::Shutdown.
|
|
let mut shutdown_received = false;
|
|
while let Ok(sub) = rx_sub.recv().await {
|
|
debug!(?sub, "Submission");
|
|
let dispatch_span = submission_dispatch_span(&sub);
|
|
let should_exit = async {
|
|
match sub.op.clone() {
|
|
Op::Interrupt => {
|
|
interrupt(&sess).await;
|
|
false
|
|
}
|
|
Op::CleanBackgroundTerminals => {
|
|
clean_background_terminals(&sess).await;
|
|
false
|
|
}
|
|
Op::RealtimeConversationStart(params) => {
|
|
if let Err(err) =
|
|
handle_realtime_conversation_start(&sess, sub.id.clone(), params).await
|
|
{
|
|
sess.send_event_raw(Event {
|
|
id: sub.id.clone(),
|
|
msg: EventMsg::Error(ErrorEvent {
|
|
message: err.to_string(),
|
|
codex_error_info: Some(CodexErrorInfo::Other),
|
|
}),
|
|
})
|
|
.await;
|
|
}
|
|
false
|
|
}
|
|
Op::RealtimeConversationAudio(params) => {
|
|
handle_realtime_conversation_audio(&sess, sub.id.clone(), params).await;
|
|
false
|
|
}
|
|
Op::RealtimeConversationText(params) => {
|
|
handle_realtime_conversation_text(&sess, sub.id.clone(), params).await;
|
|
false
|
|
}
|
|
Op::RealtimeConversationClose => {
|
|
handle_realtime_conversation_close(&sess, sub.id.clone()).await;
|
|
false
|
|
}
|
|
Op::RealtimeConversationListVoices => {
|
|
realtime_conversation_list_voices(&sess, sub.id.clone()).await;
|
|
false
|
|
}
|
|
Op::OverrideTurnContext {
|
|
cwd,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_policy,
|
|
permission_profile,
|
|
windows_sandbox_level,
|
|
model,
|
|
effort,
|
|
summary,
|
|
service_tier,
|
|
collaboration_mode,
|
|
personality,
|
|
} => {
|
|
let collaboration_mode = if let Some(collab_mode) = collaboration_mode {
|
|
collab_mode
|
|
} else {
|
|
let state = sess.state.lock().await;
|
|
state.session_configuration.collaboration_mode.with_updates(
|
|
model.clone(),
|
|
effort,
|
|
/*developer_instructions*/ None,
|
|
)
|
|
};
|
|
override_turn_context(
|
|
&sess,
|
|
sub.id.clone(),
|
|
SessionSettingsUpdate {
|
|
cwd,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_policy,
|
|
permission_profile,
|
|
windows_sandbox_level,
|
|
collaboration_mode: Some(collaboration_mode),
|
|
reasoning_summary: summary,
|
|
service_tier,
|
|
personality,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await;
|
|
false
|
|
}
|
|
Op::UserInput { .. }
|
|
| Op::UserInputWithTurnContext { .. }
|
|
| Op::UserTurn { .. } => {
|
|
user_input_or_turn(&sess, sub.id.clone(), sub.op).await;
|
|
false
|
|
}
|
|
Op::InterAgentCommunication { communication } => {
|
|
inter_agent_communication(&sess, sub.id.clone(), communication).await;
|
|
false
|
|
}
|
|
Op::ExecApproval {
|
|
id: approval_id,
|
|
turn_id,
|
|
decision,
|
|
} => {
|
|
exec_approval(&sess, approval_id, turn_id, decision).await;
|
|
false
|
|
}
|
|
Op::PatchApproval { id, decision } => {
|
|
patch_approval(&sess, id, decision).await;
|
|
false
|
|
}
|
|
Op::UserInputAnswer { id, response } => {
|
|
request_user_input_response(&sess, id, response).await;
|
|
false
|
|
}
|
|
Op::RequestPermissionsResponse { id, response } => {
|
|
request_permissions_response(&sess, id, response).await;
|
|
false
|
|
}
|
|
Op::DynamicToolResponse { id, response } => {
|
|
dynamic_tool_response(&sess, id, response).await;
|
|
false
|
|
}
|
|
Op::RefreshMcpServers { config } => {
|
|
refresh_mcp_servers(&sess, config).await;
|
|
false
|
|
}
|
|
Op::ReloadUserConfig => {
|
|
reload_user_config(&sess).await;
|
|
false
|
|
}
|
|
Op::Compact => {
|
|
compact(&sess, sub.id.clone()).await;
|
|
false
|
|
}
|
|
Op::ThreadRollback { num_turns } => {
|
|
thread_rollback(&sess, sub.id.clone(), num_turns).await;
|
|
false
|
|
}
|
|
Op::SetThreadMemoryMode { mode } => {
|
|
set_thread_memory_mode(&sess, sub.id.clone(), mode).await;
|
|
false
|
|
}
|
|
Op::RunUserShellCommand { command } => {
|
|
run_user_shell_command(&sess, sub.id.clone(), command).await;
|
|
false
|
|
}
|
|
Op::ResolveElicitation {
|
|
server_name,
|
|
request_id,
|
|
decision,
|
|
content,
|
|
meta,
|
|
} => {
|
|
resolve_elicitation(&sess, server_name, request_id, decision, content, meta)
|
|
.await;
|
|
false
|
|
}
|
|
Op::Shutdown => shutdown(&sess, sub.id.clone()).await,
|
|
Op::Review { review_request } => {
|
|
review(&sess, &config, sub.id.clone(), review_request).await;
|
|
false
|
|
}
|
|
Op::ApproveGuardianDeniedAction { event } => {
|
|
approve_guardian_denied_action(&sess, event).await;
|
|
false
|
|
}
|
|
_ => false, // Ignore unknown ops; enum is non_exhaustive to allow extensions.
|
|
}
|
|
}
|
|
.instrument(dispatch_span)
|
|
.await;
|
|
if should_exit {
|
|
shutdown_received = true;
|
|
break;
|
|
}
|
|
}
|
|
// If the submission loop exits because the channel closed without an
|
|
// explicit shutdown op, still run session teardown.
|
|
if !shutdown_received {
|
|
shutdown_session_runtime(&sess).await;
|
|
emit_thread_stop_lifecycle(sess.as_ref());
|
|
}
|
|
debug!("Agent loop exited");
|
|
}
|
|
|
|
async fn approve_guardian_denied_action(sess: &Arc<Session>, event: GuardianAssessmentEvent) {
|
|
if event.status != GuardianAssessmentStatus::Denied {
|
|
warn!(
|
|
review_id = event.id.as_str(),
|
|
"ignoring approval for non-denied Guardian assessment"
|
|
);
|
|
return;
|
|
}
|
|
|
|
let approved_action = serde_json::json!({
|
|
"action": &event.action,
|
|
"outcome": "allowed",
|
|
});
|
|
let approved_action_json = match serde_json::to_string_pretty(&approved_action) {
|
|
Ok(approved_action_json) => approved_action_json,
|
|
Err(error) => {
|
|
warn!(%error, review_id = event.id.as_str(), "failed to serialize approved Guardian action");
|
|
return;
|
|
}
|
|
};
|
|
let approval_prefix = crate::guardian::AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX;
|
|
let text = format!(
|
|
r#"{approval_prefix}
|
|
|
|
Treat this as approval to perform that exact action in the same context in which it was originally requested.
|
|
Do not assume this also authorizes similar operations with different payloads.
|
|
|
|
Approved action:
|
|
{approved_action_json}"#,
|
|
);
|
|
let items = vec![ResponseInputItem::Message {
|
|
role: "developer".to_string(),
|
|
content: vec![ContentItem::InputText { text }],
|
|
phase: None,
|
|
}];
|
|
|
|
if let Err(items) = sess.inject_response_items(items).await {
|
|
sess.queue_response_items_for_next_turn(items).await;
|
|
}
|
|
}
|
|
|
|
pub(super) fn submission_dispatch_span(sub: &Submission) -> tracing::Span {
|
|
let op_name = sub.op.kind();
|
|
let span_name = format!("op.dispatch.{op_name}");
|
|
let dispatch_span = match &sub.op {
|
|
Op::RealtimeConversationAudio(_) => {
|
|
debug_span!(
|
|
"submission_dispatch",
|
|
otel.name = span_name.as_str(),
|
|
submission.id = sub.id.as_str(),
|
|
codex.op = op_name
|
|
)
|
|
}
|
|
_ => info_span!(
|
|
"submission_dispatch",
|
|
otel.name = span_name.as_str(),
|
|
submission.id = sub.id.as_str(),
|
|
codex.op = op_name
|
|
),
|
|
};
|
|
if let Some(trace) = sub.trace.as_ref()
|
|
&& !set_parent_from_w3c_trace_context(&dispatch_span, trace)
|
|
{
|
|
warn!(
|
|
submission.id = sub.id.as_str(),
|
|
"ignoring invalid submission trace carrier"
|
|
);
|
|
}
|
|
dispatch_span
|
|
}
|