Support standalone tool outputs in turn/start (#41002)

## What changed

- Add an optional `toolOutput` to `turn/start` for starting or steering a turn with a named function-call output instead of user input.
- Emit standalone outputs as `functionCallOutput` thread items, persist them in durable history, and include them in resumed threads.
- Validate that tool outputs have a name, are not combined with nonempty `input`, satisfy the text-size limit, and use supported image URLs.

## Testing

- Cover standalone tool-output notifications, model input, and resumed history in both legacy and paginated history modes.
- Cover the text-size limit for structured tool output.

GitOrigin-RevId: a1eeec7b9b7de67b1a1c5e829a54552b85ec118b
This commit is contained in:
rka-oai
2026-08-26 23:58:10 +00:00
committed by copyberry
parent 2926014075
commit e56e4922eb
52 changed files with 3327 additions and 42 deletions
@@ -664,6 +664,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> {
text_elements: Vec::new(),
}],
turn_trigger: None,
tool_output: None,
responsesapi_client_metadata: None,
additional_context: None,
cwd: None,
@@ -5,7 +5,9 @@ use codex_agent_extension::AgentRun;
use codex_agent_extension::AgentRunner;
use codex_protocol::error::CodexErrorDetails;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry;
use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind;
use codex_protocol::protocol::TurnSettingsUpdate;
@@ -509,7 +511,38 @@ impl TurnRequestProcessor {
})?;
self.ensure_direct_input_allowed(&request_id, thread.as_ref())
.await?;
if let Err(error) = Self::validate_v2_input_limit(&params.input) {
if let Some(tool_output) = &params.tool_output {
if !params.input.is_empty() {
return Err(invalid_request(
"`toolOutput` cannot be combined with nonempty `input`",
));
}
if tool_output.name.is_empty() {
return Err(invalid_request("`toolOutput.name` must not be empty"));
}
}
let actual_chars = params
.input
.iter()
.map(V2UserInput::text_char_count)
.sum::<usize>()
+ params
.tool_output
.as_ref()
.map_or(0, |output| match &output.output {
FunctionCallOutputBody::Text(text) => text.chars().count(),
FunctionCallOutputBody::ContentItems(items) => items
.iter()
.map(|item| match item {
FunctionCallOutputContentItem::InputText { text } => {
text.chars().count()
}
_ => 0,
})
.sum(),
});
if actual_chars > MAX_USER_INPUT_TEXT_CHARS {
let error = Self::input_too_large_error(actual_chars);
self.track_error_response(
&request_id,
&error,
@@ -532,15 +565,32 @@ impl TurnRequestProcessor {
let environment_selections =
resolve_turn_environment_selections(self.thread_manager.as_ref(), params.environments)?;
// Map v2 input items to core input items.
let mapped_items: Vec<CoreInputItem> = params
.input
.into_iter()
.map(V2UserInput::into_core)
.collect();
let client_user_message_id = params.client_user_message_id;
let additional_context = map_additional_context(params.additional_context);
let turn_has_input = !mapped_items.is_empty();
let turn_has_input = !params.input.is_empty();
let input = if let Some(tool_output) = params.tool_output {
let item = ResponseItem::FunctionCallOutput {
id: None,
call_id: None,
name: Some(tool_output.name),
namespace: tool_output.namespace,
output: FunctionCallOutputPayload {
body: tool_output.output,
success: None,
},
internal_chat_message_metadata_passthrough: None,
};
validate_response_item_image_urls(std::slice::from_ref(&item))?;
TurnInput::ResponseItem(item)
} else {
TurnInput::UserInput {
content: params
.input
.into_iter()
.map(V2UserInput::into_core)
.collect(),
client_id: params.client_user_message_id,
}
};
let cwd = resolve_request_cwd(params.cwd)?;
let environments = self
.build_environment_override(
@@ -569,26 +619,25 @@ impl TurnRequestProcessor {
},
)
.await?;
self.seal_realtime_transcript_before_user_input(thread_id, &mapped_items)
.await?;
if let TurnInput::UserInput { content, .. } = &input {
self.seal_realtime_transcript_before_user_input(thread_id, content)
.await?;
}
let submission = thread
.start_or_steer_turn(
TurnInputRequest::new(TurnInput::UserInput {
content: mapped_items,
client_id: client_user_message_id,
})
.with_thread_settings(thread_settings)
.on_start(TurnStartOptions {
turn_trigger: params.turn_trigger,
final_output_json_schema: params.output_schema,
service_tier: params.service_tier_for_turn,
cyber_access_program: params.cyber_access_program.map(Into::into),
..Default::default()
})
.with_additional_context(additional_context)
.with_responses_metadata(params.responsesapi_client_metadata)
.with_trace(self.request_trace_context(&request_id).await),
TurnInputRequest::new(input)
.with_thread_settings(thread_settings)
.on_start(TurnStartOptions {
turn_trigger: params.turn_trigger,
final_output_json_schema: params.output_schema,
service_tier: params.service_tier_for_turn,
cyber_access_program: params.cyber_access_program.map(Into::into),
..Default::default()
})
.with_additional_context(additional_context)
.with_responses_metadata(params.responsesapi_client_metadata)
.with_trace(self.request_trace_context(&request_id).await),
)
.await
.map_err(|err| {