mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
768848ab6f
## Summary
Adds experimental `additionalContext` support to `turn/start` and
`turn/steer` so clients can provide ephemeral external context, such as
browser or automation state, without turning that plumbing into a
visible user prompt or triggering user-prompt lifecycle behavior.
## API Shape
The parameter shape is:
```ts
additionalContext?: Record<string, {
value: string
kind: "untrusted" | "application"
}> | null
```
Example:
```json
{
"additionalContext": {
"browser_info": {
"value": "Active tab is CI failures.",
"kind": "untrusted"
},
"automation_info": {
"value": "CI rerun is in progress.",
"kind": "application"
}
}
}
```
The keys are opaque and caller-defined.
## Context Injection
When provided, accepted entries are inserted into model context as
hidden contextual message items, not as visible thread user-message
items.
`kind: "untrusted"` entries are inserted with role `user`:
```text
<external_${key}>${value}</external_${key}>
```
`kind: "application"` entries are inserted with role `developer`:
```text
<${key}>${value}</${key}>
```
Values are not escaped. Each value is truncated to 1k approximate tokens
before wrapping.
For `turn/start`, accepted additional context is inserted before normal
user input. For `turn/steer`, additional context is merged only when the
steer includes non-empty user input; context-only steers still reject as
empty input.
## Dedupe Strategy
`AdditionalContextStore` lives on session state and stores the latest
complete additional-context map.
Each `turn/start` or non-empty `turn/steer` treats its
`additionalContext` as the current complete set of values. Entries are
injected only when the key is new or the exact entry for that key
changed, including `value` or `kind`. After merging, the store is
replaced with the provided map, so omitted keys are removed from the
retained set and can be injected again later if reintroduced.
Omitting `additionalContext`, passing `null`, or passing an empty object
resets the store to empty and injects nothing.
## What Changed
- Threads experimental v2 `additionalContext` through app-server into
core turn start and steer handling.
- Adds separate contextual fragment types for untrusted user-role
context and application developer-role context.
- Uses pending response input items so additional context can be
combined with normal user input without treating it as prompt text.
- Adds integration coverage for start/steer flow, role routing,
dedupe/reset behavior, deletion/re-add behavior, hook-blocked input
behavior, empty context-only steer rejection, external-fragment marker
matching, and truncation.
944 lines
32 KiB
Rust
944 lines
32 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::TurnInput;
|
|
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::ThreadSettingsAppliedEvent;
|
|
use codex_protocol::protocol::ThreadSettingsOverrides;
|
|
use codex_protocol::protocol::ThreadSettingsSnapshot;
|
|
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::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 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 async fn update_thread_settings(
|
|
sess: &Arc<Session>,
|
|
sub_id: String,
|
|
thread_settings: ThreadSettingsOverrides,
|
|
) {
|
|
let updates = thread_settings_update(sess, thread_settings).await;
|
|
let msg = match sess.update_settings(updates).await {
|
|
Ok(()) => thread_settings_applied_event(sess).await,
|
|
Err(err) => EventMsg::Error(ErrorEvent {
|
|
message: format!("invalid thread settings override: {err}"),
|
|
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
|
}),
|
|
};
|
|
sess.send_event_raw(Event { id: sub_id, msg }).await;
|
|
}
|
|
|
|
async fn thread_settings_update(
|
|
sess: &Session,
|
|
thread_settings: ThreadSettingsOverrides,
|
|
) -> SessionSettingsUpdate {
|
|
let ThreadSettingsOverrides {
|
|
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,
|
|
collaboration_mode,
|
|
personality,
|
|
} = thread_settings;
|
|
let collaboration_mode = match collaboration_mode {
|
|
Some(collaboration_mode) => collaboration_mode,
|
|
None => {
|
|
let state = sess.state.lock().await;
|
|
// Model and reasoning effort live in CollaborationMode settings today, so
|
|
// partial thread-settings updates refresh those fields on the active mode.
|
|
state
|
|
.session_configuration
|
|
.collaboration_mode
|
|
.with_updates(model, effort, /*developer_instructions*/ None)
|
|
}
|
|
};
|
|
SessionSettingsUpdate {
|
|
cwd,
|
|
workspace_roots,
|
|
profile_workspace_roots,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
sandbox_policy,
|
|
permission_profile,
|
|
active_permission_profile,
|
|
windows_sandbox_level,
|
|
collaboration_mode: Some(collaboration_mode),
|
|
reasoning_summary: summary,
|
|
service_tier,
|
|
personality,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
async fn thread_settings_applied_event(sess: &Session) -> EventMsg {
|
|
let snapshot = {
|
|
let state = sess.state.lock().await;
|
|
state.session_configuration.thread_config_snapshot()
|
|
};
|
|
EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent {
|
|
thread_settings: ThreadSettingsSnapshot {
|
|
model: snapshot.model,
|
|
model_provider_id: snapshot.model_provider_id,
|
|
service_tier: snapshot.service_tier,
|
|
approval_policy: snapshot.approval_policy,
|
|
approvals_reviewer: snapshot.approvals_reviewer,
|
|
permission_profile: snapshot.permission_profile,
|
|
active_permission_profile: snapshot.active_permission_profile,
|
|
cwd: snapshot.cwd,
|
|
reasoning_effort: snapshot.reasoning_effort,
|
|
reasoning_summary: snapshot.reasoning_summary,
|
|
personality: snapshot.personality,
|
|
collaboration_mode: snapshot.collaboration_mode,
|
|
},
|
|
})
|
|
}
|
|
|
|
pub(super) async fn user_input_or_turn_inner(
|
|
sess: &Arc<Session>,
|
|
sub_id: String,
|
|
op: Op,
|
|
mirror_user_text_to_realtime: Option<()>,
|
|
) {
|
|
let Op::UserInput {
|
|
items,
|
|
environments,
|
|
final_output_json_schema,
|
|
responsesapi_client_metadata,
|
|
additional_context,
|
|
thread_settings,
|
|
} = op
|
|
else {
|
|
unreachable!();
|
|
};
|
|
let emit_thread_settings_applied = thread_settings != ThreadSettingsOverrides::default();
|
|
let mut updates = if emit_thread_settings_applied {
|
|
thread_settings_update(sess, thread_settings).await
|
|
} else {
|
|
SessionSettingsUpdate::default()
|
|
};
|
|
updates.final_output_json_schema = Some(final_output_json_schema);
|
|
updates.environments = environments;
|
|
|
|
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;
|
|
};
|
|
if emit_thread_settings_applied {
|
|
sess.send_event_raw(Event {
|
|
id: sub_id.clone(),
|
|
msg: thread_settings_applied_event(sess).await,
|
|
})
|
|
.await;
|
|
}
|
|
sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref())
|
|
.await;
|
|
let accepted_items = match sess
|
|
.steer_input(
|
|
items.clone(),
|
|
additional_context.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();
|
|
let additional_context_input = {
|
|
let mut state = sess.state.lock().await;
|
|
state.additional_context.merge(additional_context)
|
|
};
|
|
let mut task_input = additional_context_input
|
|
.into_iter()
|
|
.map(TurnInput::ResponseInputItem)
|
|
.collect::<Vec<_>>();
|
|
if !items.is_empty() {
|
|
task_input.push(TurnInput::UserInput(items));
|
|
}
|
|
sess.spawn_task(
|
|
Arc::clone(¤t_context),
|
|
task_input,
|
|
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.input_queue
|
|
.enqueue_mailbox_communication(communication)
|
|
.await;
|
|
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::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;
|
|
}
|
|
|
|
async 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,
|
|
})
|
|
.await;
|
|
}
|
|
}
|
|
|
|
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()).await;
|
|
|
|
// 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::UserInput { .. } => {
|
|
user_input_or_turn(&sess, sub.id.clone(), sub.op).await;
|
|
false
|
|
}
|
|
Op::ThreadSettings { thread_settings } => {
|
|
update_thread_settings(&sess, sub.id.clone(), thread_settings).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()).await;
|
|
}
|
|
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.input_queue
|
|
.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
|
|
}
|