mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
Unify turn input submission and routing (#38275)
## What changed - Add `TurnInputRequest` and typed submission results for atomically starting a turn, steering the active turn, or declining input with a specific reason. - Expose `start_or_steer_turn`, `start_turn_if_idle`, and `steer_turn` on `CodexThread`, and migrate Core consumers to these APIs. - Make app-server `turn/start` steer an active regular turn and return that turn's ID. Reject incompatible output schemas and non-steerable turns without applying settings or enqueueing input. ## Testing - Cover concurrent start-or-steer submissions, accepted and rejected settings updates, output-schema compatibility, idle-start rejection, and app-server steering. GitOrigin-RevId: dd9b5528d76ec650c019e97af420bc13190ea86a
This commit is contained in:
1 parent
4b07886d59
commit
cbb7e82a8b
137 files changed
+5217
-6812
No files matched your search
@@ -1,5 +1,4 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fmt::Debug;
|
||||
@@ -92,9 +91,7 @@ use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::AutoCompactTokenLimitScope;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
@@ -111,7 +108,6 @@ use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AdditionalContextEntry;
|
||||
use codex_protocol::protocol::FileChange;
|
||||
use codex_protocol::protocol::HasLegacyEvent;
|
||||
use codex_protocol::protocol::HistoryPosition;
|
||||
@@ -221,17 +217,19 @@ mod rollout_reconstruction;
|
||||
#[allow(clippy::module_inception)]
|
||||
pub(crate) mod session;
|
||||
pub(crate) mod step_context;
|
||||
mod thread_settings;
|
||||
pub(crate) mod time_reminder;
|
||||
mod token_budget;
|
||||
pub(crate) mod turn;
|
||||
pub(crate) mod turn_context;
|
||||
mod turn_input;
|
||||
mod world_state;
|
||||
use self::code_mode_warning::unsupported_code_mode_warning;
|
||||
#[cfg(test)]
|
||||
use self::handlers::submission_dispatch_span;
|
||||
use self::handlers::submission_loop;
|
||||
pub(crate) use self::input_queue::InputQueueActivity;
|
||||
pub use self::input_queue::TurnInput;
|
||||
pub(crate) use self::input_queue::TurnInput;
|
||||
pub(crate) use self::input_queue::TurnInputQueue;
|
||||
use self::review::spawn_review_thread;
|
||||
use self::session::AppServerClientMetadata;
|
||||
@@ -248,45 +246,6 @@ use self::turn_context::TurnContext;
|
||||
#[cfg(test)]
|
||||
mod rollout_reconstruction_tests;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum SteerInputError {
|
||||
NoActiveTurn(Vec<UserInput>),
|
||||
ExpectedTurnMismatch { expected: String, actual: String },
|
||||
ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind },
|
||||
EmptyInput,
|
||||
}
|
||||
|
||||
impl SteerInputError {
|
||||
fn to_error_event(&self) -> ErrorEvent {
|
||||
match self {
|
||||
Self::NoActiveTurn(_) => ErrorEvent {
|
||||
message: "no active turn to steer".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
},
|
||||
Self::ExpectedTurnMismatch { expected, actual } => ErrorEvent {
|
||||
message: format!("expected active turn id `{expected}` but found `{actual}`"),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
},
|
||||
Self::ActiveTurnNotSteerable { turn_kind } => {
|
||||
let turn_kind_label = match turn_kind {
|
||||
NonSteerableTurnKind::Review => "review",
|
||||
NonSteerableTurnKind::Compact => "compact",
|
||||
};
|
||||
ErrorEvent {
|
||||
message: format!("cannot steer a {turn_kind_label} turn"),
|
||||
codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable {
|
||||
turn_kind: *turn_kind,
|
||||
}),
|
||||
}
|
||||
}
|
||||
Self::EmptyInput => ErrorEvent {
|
||||
message: "input must not be empty".to_string(),
|
||||
codex_error_info: Some(CodexErrorInfo::BadRequest),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes from the previous real user turn.
|
||||
///
|
||||
/// Conceptually this is the same role that `previous_model` used to fill, but
|
||||
@@ -344,8 +303,10 @@ use codex_otel::THREAD_STARTED_METRIC;
|
||||
use codex_otel::TelemetryAuthMode;
|
||||
use codex_protocol::ResponseItemId;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::mcp::ClientMcpExtensions;
|
||||
use codex_protocol::models::LocalImagePreparation;
|
||||
@@ -356,7 +317,6 @@ use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::DeprecationNoticeEvent;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecApprovalRequestEvent;
|
||||
@@ -365,7 +325,6 @@ use codex_protocol::protocol::ModelRerouteReason;
|
||||
use codex_protocol::protocol::ModelVerification;
|
||||
use codex_protocol::protocol::ModelVerificationEvent;
|
||||
use codex_protocol::protocol::NetworkApprovalContext;
|
||||
use codex_protocol::protocol::NonSteerableTurnKind;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_protocol::protocol::RequestUserInputEvent;
|
||||
@@ -381,6 +340,9 @@ use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
use codex_protocol::protocol::TurnModerationMetadataEvent;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::turn_input::TurnInputMode;
|
||||
use codex_protocol::turn_input::TurnInputRequest;
|
||||
use codex_protocol::turn_input::TurnInputSubmission;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_skills_extension::HostSkillsService;
|
||||
use codex_tools::UnifiedExecShellMode;
|
||||
@@ -847,7 +809,6 @@ impl SessionIo {
|
||||
let sub = Submission {
|
||||
id: id.clone(),
|
||||
op,
|
||||
client_user_message_id: None,
|
||||
trace,
|
||||
parent_turn_id,
|
||||
root_turn_id,
|
||||
@@ -856,26 +817,6 @@ impl SessionIo {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub(crate) async fn submit_user_input_with_client_user_message_id(
|
||||
&self,
|
||||
op: Op,
|
||||
trace: Option<W3cTraceContext>,
|
||||
client_user_message_id: Option<String>,
|
||||
) -> CodexResult<String> {
|
||||
debug_assert!(matches!(op, Op::UserInput { .. }));
|
||||
let id = new_submission_id();
|
||||
let sub = Submission {
|
||||
id: id.clone(),
|
||||
op,
|
||||
client_user_message_id,
|
||||
trace,
|
||||
parent_turn_id: None,
|
||||
root_turn_id: None,
|
||||
};
|
||||
self.submit_with_id(sub).await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Use sparingly: prefer `submit()` so submission IDs are generated consistently.
|
||||
pub(crate) async fn submit_with_id(&self, mut sub: Submission) -> CodexResult<()> {
|
||||
if sub.trace.is_none() {
|
||||
@@ -888,6 +829,33 @@ impl SessionIo {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Submits an ordered turn-input call and waits only for Core's routing decision.
|
||||
///
|
||||
/// Once queued, dropping the waiter does not retract the call. If the
|
||||
/// session loop exits before replying, the caller gets `InternalAgentDied`.
|
||||
pub(crate) async fn submit_turn_input(
|
||||
&self,
|
||||
mut request: TurnInputRequest,
|
||||
mode: TurnInputMode,
|
||||
) -> CodexResult<TurnInputSubmission> {
|
||||
let id = new_submission_id();
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
let trace = request.trace.take();
|
||||
self.submit_with_id(Submission {
|
||||
id,
|
||||
op: Op::TurnInput {
|
||||
request: Box::new(request),
|
||||
mode,
|
||||
reply: reply_tx,
|
||||
},
|
||||
trace,
|
||||
parent_turn_id: None,
|
||||
root_turn_id: None,
|
||||
})
|
||||
.await?;
|
||||
reply_rx.await.unwrap_or(Err(CodexErr::InternalAgentDied))
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown_and_wait(&self) -> CodexResult<()> {
|
||||
let session_loop_termination = self.session_loop_termination.clone();
|
||||
match self.submit(Op::Shutdown).await {
|
||||
@@ -1248,27 +1216,6 @@ impl Session {
|
||||
format!("auto-compact-{id}")
|
||||
}
|
||||
|
||||
pub(crate) async fn route_realtime_text_input(self: &Arc<Self>, text: String) {
|
||||
handlers::user_input_or_turn(
|
||||
self,
|
||||
Uuid::now_v7().to_string(),
|
||||
Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text,
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: Default::default(),
|
||||
},
|
||||
/*client_user_message_id*/ None,
|
||||
/*parent_turn_id*/ None,
|
||||
/*root_turn_id*/ None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn get_total_token_usage(&self) -> i64 {
|
||||
let state = self.state.lock().await;
|
||||
state.get_total_token_usage(state.server_reasoning_included())
|
||||
@@ -1434,7 +1381,7 @@ impl Session {
|
||||
}
|
||||
|
||||
let thread_settings_applied =
|
||||
RolloutItem::EventMsg(handlers::thread_settings_applied_event(self).await);
|
||||
RolloutItem::EventMsg(thread_settings::applied_event(self).await);
|
||||
match &self.fork_persistence {
|
||||
ForkPersistence::Referenced {
|
||||
inherited_item_count,
|
||||
@@ -4043,99 +3990,6 @@ impl Session {
|
||||
self.send_event(turn_context, event).await;
|
||||
}
|
||||
|
||||
/// Inject additional user input into the currently active turn.
|
||||
///
|
||||
/// Returns the active turn id when accepted.
|
||||
#[expect(
|
||||
clippy::await_holding_invalid_type,
|
||||
reason = "active turn checks and turn state updates must remain atomic"
|
||||
)]
|
||||
pub async fn steer_input(
|
||||
&self,
|
||||
input: Vec<UserInput>,
|
||||
additional_context: BTreeMap<String, AdditionalContextEntry>,
|
||||
expected_turn_id: Option<&str>,
|
||||
client_user_message_id: Option<String>,
|
||||
responsesapi_client_metadata: Option<HashMap<String, String>>,
|
||||
incoming_turn_metadata: Option<&TurnMetadataState>,
|
||||
) -> Result<String, SteerInputError> {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
let Some(active_turn) = active.as_mut() else {
|
||||
return Err(SteerInputError::NoActiveTurn(input));
|
||||
};
|
||||
|
||||
let Some(active_task) = active_turn.task.as_ref() else {
|
||||
return Err(SteerInputError::NoActiveTurn(input));
|
||||
};
|
||||
let active_turn_id = &active_task.turn_context.sub_id;
|
||||
|
||||
if let Some(expected_turn_id) = expected_turn_id
|
||||
&& expected_turn_id != active_turn_id
|
||||
{
|
||||
return Err(SteerInputError::ExpectedTurnMismatch {
|
||||
expected: expected_turn_id.to_string(),
|
||||
actual: active_turn_id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
match active_task.kind {
|
||||
crate::state::TaskKind::Regular => {}
|
||||
crate::state::TaskKind::Review => {
|
||||
return Err(SteerInputError::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Review,
|
||||
});
|
||||
}
|
||||
crate::state::TaskKind::Compact => {
|
||||
return Err(SteerInputError::ActiveTurnNotSteerable {
|
||||
turn_kind: NonSteerableTurnKind::Compact,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if input.is_empty() {
|
||||
return Err(SteerInputError::EmptyInput);
|
||||
}
|
||||
|
||||
let additional_context_input = {
|
||||
let mut state = self.state.lock().await;
|
||||
state.additional_context.merge(additional_context)
|
||||
};
|
||||
|
||||
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
|
||||
active_task
|
||||
.turn_context
|
||||
.turn_metadata_state
|
||||
.set_responsesapi_client_metadata(responsesapi_client_metadata);
|
||||
}
|
||||
|
||||
let mut pending_input = additional_context_input
|
||||
.into_iter()
|
||||
.map(ResponseItem::from)
|
||||
.map(|item| self.annotate_client_response_item(item))
|
||||
.map(TurnInput::ResponseItem)
|
||||
.collect::<Vec<_>>();
|
||||
pending_input.push(TurnInput::UserInput {
|
||||
content: input,
|
||||
client_id: client_user_message_id.clone(),
|
||||
});
|
||||
if let Some(incoming_turn_metadata) = incoming_turn_metadata
|
||||
&& active_task.turn_context.turn_metadata_state.root_turn_id()
|
||||
!= incoming_turn_metadata.root_turn_id()
|
||||
{
|
||||
active_task
|
||||
.turn_context
|
||||
.turn_metadata_state
|
||||
.mark_root_turn_ambiguous();
|
||||
}
|
||||
self.input_queue
|
||||
.extend_pending_input_and_accept_mailbox_delivery_for_turn_state(
|
||||
active_turn.turn_state.as_ref(),
|
||||
pending_input,
|
||||
)
|
||||
.await;
|
||||
Ok(active_turn_id.clone())
|
||||
}
|
||||
|
||||
pub(crate) async fn record_memory_citation_for_turn(&self, sub_id: &str) {
|
||||
let turn_state = self
|
||||
.input_queue
|
||||
|
||||
Reference in new issue
Block a user