Add opt-in retention for turn tool outputs (#52686)

## What changed

Add an optional `retain` flag to `TurnToolOutput` for `turn/start`. When set to `true`, pass the output to the core with a retention annotation to request retention in the thread's model history. The flag defaults to `false`, preserving existing behavior.

Update the generated JSON schemas, TypeScript types, and Python bindings to expose the flag.

## Testing

Add an integration test verifying that a retained tool output reaches the model and preserves client-authored sender metadata in persisted history, including the receiver message ID.

GitOrigin-RevId: 52893a8fcb308f26e77ba8f0bee9990073b26d54
This commit is contained in:
Won Park
2026-10-09 20:02:18 +00:00
committed by copyberry
parent c261727370
commit 45311351c2
12 changed files with 126 additions and 2 deletions
@@ -5928,6 +5928,10 @@
},
"output": {
"$ref": "#/definitions/FunctionCallOutputBody"
},
"retain": {
"description": "Requests retention of this output in the thread's model history.",
"type": "boolean"
}
},
"required": [
@@ -26301,6 +26301,10 @@
},
"output": {
"$ref": "#/definitions/v2/FunctionCallOutputBody"
},
"retain": {
"description": "Requests retention of this output in the thread's model history.",
"type": "boolean"
}
},
"required": [
@@ -24015,6 +24015,10 @@
},
"output": {
"$ref": "#/definitions/FunctionCallOutputBody"
},
"retain": {
"description": "Requests retention of this output in the thread's model history.",
"type": "boolean"
}
},
"required": [
@@ -533,6 +533,10 @@
},
"output": {
"$ref": "#/definitions/FunctionCallOutputBody"
},
"retain": {
"description": "Requests retention of this output in the thread's model history.",
"type": "boolean"
}
},
"required": [
@@ -3,4 +3,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FunctionCallOutputBody } from "../FunctionCallOutputBody";
export type TurnToolOutput = { name: string, namespace: string | null, output: FunctionCallOutputBody, };
export type TurnToolOutput = { name: string, namespace: string | null, output: FunctionCallOutputBody,
/**
* Requests retention of this output in the thread's model history.
*/
retain?: boolean, };
@@ -157,6 +157,9 @@ pub struct TurnToolOutput {
pub name: String,
pub namespace: Option<String>,
pub output: FunctionCallOutputBody,
/// Requests retention of this output in the thread's model history.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub retain: bool,
}
#[derive(
@@ -15,6 +15,8 @@ use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind
use codex_protocol::protocol::TurnEnvironmentRequests;
use codex_protocol::protocol::TurnSettingsUpdate;
use codex_protocol::protocol::TurnSettingsUpdateOutcome;
use codex_protocol::turn_input::AnnotatedResponseItem;
use codex_protocol::turn_input::ResponseItemAnnotations;
use codex_skills::system_cache_root_dir;
use crate::image_url::REMOTE_IMAGE_URL_ERROR;
@@ -608,7 +610,14 @@ impl TurnRequestProcessor {
internal_chat_message_metadata_passthrough: None,
};
validate_response_item_image_urls(std::slice::from_ref(&item))?;
TurnInput::ResponseItem(item)
if tool_output.retain {
TurnInput::AnnotatedResponseItem(AnnotatedResponseItem {
item,
annotations: ResponseItemAnnotations { retain: true },
})
} else {
TurnInput::ResponseItem(item)
}
} else {
TurnInput::UserInput {
content: params
@@ -297,6 +297,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history(
thread_id: thread.id.clone(),
input: Vec::new(),
tool_output: Some(Box::new(TurnToolOutput {
retain: false,
name: "send_message_to_thread".to_string(),
namespace: Some("codex_app".to_string()),
output: FunctionCallOutputBody::Text("Start a delegated turn.".to_string()),
@@ -655,3 +656,89 @@ fn response_item_text_position(items: &[Value], needle: &str) -> Option<usize> {
})
})
}
#[tokio::test]
async fn turn_tool_output_retention_preserves_sender_metadata() -> Result<()> {
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_once(
&server,
responses::sse(vec![responses::ev_completed("delegated-turn")]),
)
.await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let mut app = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build()
.await?;
app.initialize().await?;
let request = app
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
.await?;
let ThreadStartResponse { thread, .. } = app.read_response(request).await?;
let source_thread_id = ThreadId::new();
let output = format!(
"<codex_delegation>\n <source_thread_id>{source_thread_id}</source_thread_id>\n <input>Investigate the failing tests.</input>\n</codex_delegation>"
);
let request = app
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: Vec::new(),
tool_output: Some(Box::new(TurnToolOutput {
name: "send_message_to_thread".to_string(),
namespace: Some("codex_app".to_string()),
output: FunctionCallOutputBody::Text(output.clone()),
retain: true,
})),
..Default::default()
})
.await?;
let _: TurnStartResponse = app.read_response(request).await?;
app.read_stream_until_notification_message("turn/completed")
.await?;
let model_outputs = response_mock
.single_request()
.inputs_of_type("function_call_output")
.into_iter()
.map(responses::strip_metadata_from_json)
.map(strip_response_item_ids_from_json)
.collect::<Vec<_>>();
assert_eq!(
model_outputs,
vec![json!({
"type": "function_call_output",
"name": "send_message_to_thread",
"namespace": "codex_app",
"output": output,
})]
);
let rollout_path = thread.path.as_ref().context("thread path missing")?;
let InitialHistory::Resumed(history) =
RolloutRecorder::get_rollout_history(rollout_path).await?
else {
panic!("expected persisted history");
};
let envelope = history
.history
.iter()
.find_map(|item| match item {
RolloutItem::ResponseItem(envelope)
if matches!(envelope.item, ResponseItem::FunctionCallOutput { .. }) =>
{
Some(envelope)
}
_ => None,
})
.context("delegation output should be saved")?;
let metadata = envelope.metadata.as_ref().context("output metadata")?;
assert!(metadata.client_authored);
let sender_messages = metadata
.sender_user_messages
.as_ref()
.context("sender context must be preserved")?;
assert_eq!(
sender_messages.receiver_message_id,
envelope.item.id().unwrap().to_string()
);
Ok(())
}
+1
View File
@@ -1243,6 +1243,7 @@ async fn start_turn(
// Older app-server/TUI versions are intentionally unsupported:
// preserving tool authority takes precedence over legacy fallback.
tool_output: Some(Box::new(TurnToolOutput {
retain: false,
name: tool.to_string(),
namespace: Some(NAMESPACE.to_string()),
output: FunctionCallOutputBody::Text(prompt),
@@ -11832,6 +11832,10 @@ class TurnToolOutput(BaseModel):
name: str
namespace: str | None = None
output: FunctionCallOutputBody
retain: Annotated[
bool | None,
Field(description="Requests retention of this output in the thread's model history."),
] = None
class TurnsPage(BaseModel):