Support standalone function outputs in turn routing (#40991)

## What changed

- Allow `start_or_steer_turn` to accept named `function_call_output` items without a `call_id`, starting a turn when idle or steering the active turn.
- Preserve standalone outputs as pending input, assign response item IDs, and record them in conversation history without treating them as user prompts.

## Testing

- Cover starting a turn with a standalone output and steering an active turn with one.
- Verify passive response items do not signal pending steering activity.

GitOrigin-RevId: e32b292106b3c5a5f03577b8ebf67d2d7dee4e5d
This commit is contained in:
rka-oai authored and copyberry committed 2026-08-26 22:48:37 +00:00
1 parent 6e008417bb
commit b9c4b9a0cf
9 files changed
+178 -61

No files matched your search

+51 -44
View File
@@ -21,6 +21,7 @@ use crate::tasks::RegularTask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AdditionalContextEntry;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
@@ -234,21 +235,25 @@ async fn start_or_steer(
submission_id: String,
) -> CodexResult<TurnInputSubmission> {
let TurnInputRequest {
input,
mut input,
thread_settings,
start,
additional_context,
responsesapi_client_metadata,
..
} = request;
let SubmittedTurnInput::UserInput {
content: mut items,
client_id,
} = input
else {
return Err(CodexErr::InvalidRequest(
"only user input can steer a turn".to_string(),
));
let has_explicit_input = match &input {
SubmittedTurnInput::UserInput { content, .. } => !content.is_empty(),
SubmittedTurnInput::ResponseItem(ResponseItem::FunctionCallOutput {
call_id: None,
..
}) => true,
_ => {
return Err(CodexErr::InvalidRequest(
"only user input or standalone function-call outputs can start or steer a turn"
.to_string(),
));
}
};
let can_start_root_turn = start.parent_turn_id.is_none() && start.root_turn_id.is_none();
let incoming_root_turn_id = start
@@ -258,11 +263,10 @@ async fn start_or_steer(
let settings = PreparedTurnInputSettings::prepare(session, thread_settings, start).await?;
match session
.steer_input(
&mut items,
&mut input,
additional_context.clone(),
/*expected_turn_id*/ None,
settings.required_active_final_output_json_schema(),
client_id.clone(),
responsesapi_client_metadata.clone(),
incoming_root_turn_id,
)
@@ -280,7 +284,7 @@ async fn start_or_steer(
unreachable!("explicit user input can enter Plan mode");
};
if can_start_root_turn
&& !items.is_empty()
&& has_explicit_input
&& turn_context
.turn_metadata_state
.can_start_root_turn(&turn_context.session_source)
@@ -297,13 +301,12 @@ async fn start_or_steer(
session
.maybe_emit_model_warnings_for_turn(turn_context.as_ref())
.await;
turn_context.session_telemetry.user_prompt(&items);
if let SubmittedTurnInput::UserInput { content, .. } = &input {
turn_context.session_telemetry.user_prompt(content);
}
let mut task_input = merge_additional_context_input(session, additional_context).await;
if !items.is_empty() {
task_input.push(TurnInput::UserInput {
content: items,
client_id,
});
if has_explicit_input {
task_input.push(pending_turn_input(input));
}
session
.spawn_task(turn_context, task_input, RegularTask::new())
@@ -447,22 +450,18 @@ async fn steer(
submission_id: String,
) -> CodexResult<TurnInputSubmission> {
let TurnInputRequest {
input,
mut input,
thread_settings,
start,
additional_context,
responsesapi_client_metadata,
..
} = request;
let SubmittedTurnInput::UserInput {
content: mut items,
client_id,
} = input
else {
if !matches!(&input, SubmittedTurnInput::UserInput { .. }) {
return Err(CodexErr::InvalidRequest(
"only user input can steer a turn".to_string(),
));
};
}
let incoming_root_turn_id = start
.parent_turn_id
.as_ref()
@@ -470,11 +469,10 @@ async fn steer(
let settings = PreparedTurnInputSettings::prepare(session, thread_settings, start).await?;
match session
.steer_input(
&mut items,
&mut input,
additional_context,
Some(expected_turn_id.as_str()),
settings.required_active_final_output_json_schema(),
client_id,
responsesapi_client_metadata,
incoming_root_turn_id,
)
@@ -538,24 +536,19 @@ impl Session {
}
}
/// Inject additional user input into the currently active turn.
/// Inject additional user input or a standalone tool output into the 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"
)]
#[expect(
clippy::too_many_arguments,
reason = "steering carries the accepted input plus its turn-scoped metadata"
)]
async fn steer_input(
&self,
input: &mut Vec<UserInput>,
input: &mut SubmittedTurnInput,
additional_context: BTreeMap<String, AdditionalContextEntry>,
expected_turn_id: Option<&str>,
required_final_output_json_schema: Option<&Value>,
client_user_message_id: Option<String>,
responsesapi_client_metadata: Option<HashMap<String, String>>,
incoming_root_turn_id: Option<Option<String>>,
) -> Result<String, NotSubmittedReason> {
@@ -592,7 +585,7 @@ impl Session {
}
}
if input.is_empty() {
if matches!(input, SubmittedTurnInput::UserInput { content, .. } if content.is_empty()) {
return Err(NotSubmittedReason::EmptyInput);
}
// Compare JSON values directly instead of serialized schema text.
@@ -603,11 +596,6 @@ impl Session {
{
return Err(NotSubmittedReason::ActiveTurnOutputSchemaMismatch);
}
active_task
.turn_context
.session_telemetry
.user_prompt(input);
let mut pending_input = merge_additional_context_input(self, additional_context).await;
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
@@ -617,10 +605,20 @@ impl Session {
.set_responsesapi_client_metadata(responsesapi_client_metadata);
}
pending_input.push(TurnInput::UserInput {
content: std::mem::take(input),
client_id: client_user_message_id,
});
let input = match input {
SubmittedTurnInput::UserInput { content, client_id } => {
active_task
.turn_context
.session_telemetry
.user_prompt(content);
TurnInput::UserInput {
content: std::mem::take(content),
client_id: client_id.clone(),
}
}
input => pending_turn_input(input.clone()),
};
pending_input.push(input);
if let Some(incoming_root_turn_id) = incoming_root_turn_id
&& active_task.turn_context.turn_metadata_state.root_turn_id() != incoming_root_turn_id
{
@@ -659,6 +657,15 @@ fn pending_turn_input(input: SubmittedTurnInput) -> TurnInput {
SubmittedTurnInput::UserInput { content, client_id } => {
TurnInput::UserInput { content, client_id }
}
SubmittedTurnInput::ResponseItem(mut item)
if matches!(
&item,
ResponseItem::FunctionCallOutput { call_id: None, .. }
) =>
{
Session::assign_missing_response_item_id(&mut item);
TurnInput::FunctionCallOutput(item)
}
SubmittedTurnInput::ResponseItem(item) => TurnInput::ResponseItem(item.into()),
SubmittedTurnInput::InterAgentCommunication(communication) => {
TurnInput::InterAgentCommunication(communication)