diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index ad37f6d5a..bd037deb5 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -28,7 +28,8 @@ pub struct CompactionInput<'a> { pub input: &'a [ResponseItem], #[serde(skip_serializing_if = "str::is_empty")] pub instructions: &'a str, - pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, pub parallel_tool_calls: bool, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning: Option, @@ -210,7 +211,8 @@ pub struct ResponsesApiRequest { #[serde(skip_serializing_if = "String::is_empty")] pub instructions: String, pub input: Vec, - pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, pub tool_choice: String, pub parallel_tool_calls: bool, pub reasoning: Option, @@ -258,7 +260,8 @@ pub struct ResponseCreateWsRequest { #[serde(skip_serializing_if = "Option::is_none")] pub previous_response_id: Option, pub input: Vec, - pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, pub tool_choice: String, pub parallel_tool_calls: bool, pub reasoning: Option, diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index ea7565ef5..755228a9f 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -838,11 +838,11 @@ mod tests { phase: None, internal_chat_message_metadata_passthrough: None, }], - tools: vec![json!({ + tools: Some(vec![json!({ "type": "function", "name": "lookup", "parameters": {"type": "object"} - })], + })]), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 2d69f8386..60a9852cf 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -315,7 +315,7 @@ async fn responses_client_stream_request_preserves_item_ids() -> Result<()> { phase: None, internal_chat_message_metadata_passthrough: None, }], - tools: Vec::new(), + tools: Some(Vec::new()), tool_choice: "auto".into(), parallel_tool_calls: false, reasoning: None, @@ -401,7 +401,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> { model: "gpt-test".into(), instructions: "Say hi".into(), input: Vec::new(), - tools: Vec::new(), + tools: Some(Vec::new()), tool_choice: "auto".into(), parallel_tool_calls: false, reasoning: None, @@ -520,7 +520,7 @@ async fn azure_store_sends_ids_and_headers() -> Result<()> { phase: None, internal_chat_message_metadata_passthrough: None, }], - tools: Vec::new(), + tools: Some(Vec::new()), tool_choice: "auto".into(), parallel_tool_calls: false, reasoning: None, diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 05fac6aaf..37a3a8105 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -41,7 +41,8 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: _ => false, }, RolloutItem::ResponseItem( - ResponseItem::AgentMessage { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index a01cd7826..9e240e968 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -73,6 +73,7 @@ use codex_otel::current_span_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::Verbosity as VerbosityConfig; +use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -790,7 +791,6 @@ impl ModelClient { service_tier: Option, responses_metadata: &CodexResponsesMetadata, ) -> Result { - let instructions = &prompt.base_instructions.text; let mut input = prompt.get_formatted_input_for_request(model_info.use_responses_lite); if !self.state.provider.info().is_openai() { input @@ -798,6 +798,28 @@ impl ModelClient { .for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough); } let tools = create_tools_json_for_responses_api(&prompt.tools)?; + let (instructions, tools) = if model_info.use_responses_lite { + let mut prefix = vec![ResponseItem::AdditionalTools { + id: None, + role: "developer".to_string(), + tools, + }]; + if !prompt.base_instructions.text.is_empty() { + prefix.push(ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: prompt.base_instructions.text.clone(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }); + } + input.splice(0..0, prefix); + (String::new(), None) + } else { + (prompt.base_instructions.text.clone(), Some(tools)) + }; let reasoning = Self::build_reasoning(model_info, effort, summary); let include = if reasoning.is_some() { vec!["reasoning.encrypted_content".to_string()] @@ -824,7 +846,7 @@ impl ModelClient { let service_tier = model_info.service_tier_for_request(service_tier); let request = ResponsesApiRequest { model: model_info.slug.clone(), - instructions: instructions.clone(), + instructions, input, tools, tool_choice: "auto".to_string(), diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index e0c2ff03c..54ad70daa 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -83,7 +83,8 @@ fn strip_image_details(items: &mut [ResponseItem]) { } } } - ResponseItem::Reasoning { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 8b25c7e30..38c4aaecb 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -110,7 +110,7 @@ fn serializes_text_verbosity_when_set() { model: "gpt-5.4".to_string(), instructions: "i".to_string(), input, - tools, + tools: Some(tools), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, @@ -157,7 +157,7 @@ fn serializes_text_schema_with_strict_format() { model: "gpt-5.4".to_string(), instructions: "i".to_string(), input, - tools, + tools: Some(tools), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, @@ -218,7 +218,7 @@ fn omits_text_when_not_set() { model: "gpt-5.4".to_string(), instructions: "i".to_string(), input, - tools, + tools: Some(tools), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, @@ -241,7 +241,7 @@ fn serializes_flex_service_tier_when_set() { model: "gpt-5.4".to_string(), instructions: "i".to_string(), input: vec![], - tools: vec![], + tools: Some(vec![]), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 3d2314068..48784141a 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -352,7 +352,8 @@ pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { ResponseItem::AgentMessage { .. } => true, ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger { .. } => false, - ResponseItem::Reasoning { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 0244f7f96..b474ed1a9 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -386,7 +386,8 @@ impl ContextManager { output: truncate_function_output_payload(output, policy_with_serialization_budget), internal_chat_message_metadata_passthrough: metadata.clone(), }, - ResponseItem::Message { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Message { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } @@ -476,7 +477,8 @@ pub(crate) fn truncate_function_output_payload( fn is_api_message(message: &ResponseItem) -> bool { match message { ResponseItem::Message { role, .. } => role.as_str() != "system", - ResponseItem::AgentMessage { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::FunctionCallOutput { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } @@ -722,7 +724,8 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger { .. } => false, - ResponseItem::FunctionCallOutput { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::FunctionCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::AgentMessage { .. } diff --git a/codex-rs/core/src/image_preparation.rs b/codex-rs/core/src/image_preparation.rs index 87ac444df..111ce9b95 100644 --- a/codex-rs/core/src/image_preparation.rs +++ b/codex-rs/core/src/image_preparation.rs @@ -57,7 +57,8 @@ pub(crate) fn prepare_response_items(items: &mut [ResponseItem]) { prepare_tool_output_content(content); } } - ResponseItem::Reasoning { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index a199d5b2e..95db34858 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2715,6 +2715,7 @@ impl Session { continue; } let prefix = match item { + ResponseItem::AdditionalTools { .. } => "at", ResponseItem::Message { .. } => "msg", ResponseItem::Reasoning { .. } => "rs", ResponseItem::LocalShellCall { .. } => "lsh", diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 378dff58c..52bde2cc4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -222,6 +222,19 @@ fn assign_missing_response_item_ids_skips_agent_messages() { assert!(items[1].id().is_some_and(|id| id.starts_with("msg_"))); } +#[test] +fn assign_missing_response_item_ids_assigns_additional_tools_ids() { + let items = Cow::Owned(vec![ResponseItem::AdditionalTools { + id: None, + role: "developer".to_string(), + tools: Vec::new(), + }]); + + let items = Session::assign_missing_response_item_ids(items); + + assert!(items[0].id().is_some_and(|id| id.starts_with("at_"))); +} + fn assistant_message(text: &str) -> ResponseItem { ResponseItem::Message { id: None, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index cdb8468e5..84442a6ca 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -2091,7 +2091,8 @@ async fn try_run_sampling_request( } ResponseItem::Reasoning { .. } => true, ResponseItem::AgentMessage { .. } => false, - ResponseItem::LocalShellCall { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::FunctionCallOutput { .. } diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index e7a90800b..04f7aeb69 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -380,7 +380,8 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool { | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger { .. } => false, - ResponseItem::FunctionCallOutput { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::Other => false, diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index f3e104778..9e75e36e9 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -1,6 +1,10 @@ use anyhow::Result; use codex_features::Feature; use codex_protocol::config_types::ServiceTier; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::user_input::UserInput; use core_test_support::responses::WebSocketConnectionConfig; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -10,12 +14,89 @@ use core_test_support::responses::start_websocket_server; use core_test_support::responses::start_websocket_server_with_headers; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; use std::time::Duration; const WS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_model_switch_to_responses_lite_omits_top_level_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("warm-1"), ev_completed("warm-1")], + vec![ev_response_created("resp-1"), ev_completed("resp-1")], + vec![ev_response_created("resp-2"), ev_completed("resp-2")], + ]]) + .await; + + let mut builder = test_codex() + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + }) + .with_model("gpt-5.3-codex"); + let test = builder.build_with_websocket_server(&server).await?; + + test.submit_turn("non-lite turn").await?; + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "lite turn".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: ThreadSettingsOverrides { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert_eq!(server.handshakes().len(), 1); + let connection = server.single_connection(); + assert_eq!(connection.len(), 3); + let non_lite_turn = connection + .get(1) + .expect("missing non-lite turn request") + .body_json(); + let lite_turn = connection + .get(2) + .expect("missing lite turn request") + .body_json(); + + assert_eq!(non_lite_turn["model"].as_str(), Some("gpt-5.3-codex")); + assert_eq!(lite_turn["model"].as_str(), Some("gpt-5.4")); + assert!( + non_lite_turn + .get("tools") + .and_then(Value::as_array) + .is_some_and(|tools| !tools.is_empty()) + ); + assert_eq!(lite_turn.get("previous_response_id"), None); + assert_eq!(lite_turn.get("tools"), None); + assert_eq!(lite_turn.get("instructions"), None); + let additional_tools = lite_turn + .get("input") + .and_then(Value::as_array) + .and_then(|input| input.first()) + .filter(|item| item.get("type").and_then(Value::as_str) == Some("additional_tools")) + .and_then(|item| item.get("tools")) + .and_then(Value::as_array) + .expect("lite turn should start with an additional_tools item"); + assert!(!additional_tools.is_empty()); + + server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn websocket_test_codex_shell_chain() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index 8e633522f..4d2a38f6f 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -54,6 +54,70 @@ fn has_hosted_tool(tools: &[Value], tool_type: &str) -> bool { .any(|tool| tool.get("type").and_then(Value::as_str) == Some(tool_type)) } +fn additional_tools(body: &Value) -> Result<&[Value]> { + body["input"] + .as_array() + .context("Responses request input should be an array")? + .first() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("additional_tools")) + .context("Responses request should start with additional_tools")?["tools"] + .as_array() + .map(Vec::as_slice) + .context("additional_tools tools should be an array") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + }) + .with_config(|config| { + config.base_instructions = Some("test instructions".to_string()); + }); + let test = builder.build(&server).await?; + + test.submit_turn("hello").await?; + + let body = response_mock.single_request().body_json(); + assert!(body.get("instructions").is_none()); + assert!(body.get("tools").is_none()); + + let input = body["input"] + .as_array() + .context("Responses request input should be an array")?; + assert_eq!(input[0]["type"], "additional_tools"); + assert_eq!(input[0]["role"], "developer"); + assert_eq!( + input[1], + serde_json::json!({ + "type": "message", + "role": "developer", + "content": [{ + "type": "input_text", + "text": "test instructions", + }], + }) + ); + + let tools = additional_tools(&body)?; + assert!(!tools.is_empty()); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_lite_prepares_images() -> Result<()> { skip_if_no_network!(Ok(())); @@ -158,17 +222,10 @@ async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Res request.header(RESPONSES_LITE_HEADER).as_deref(), Some("true") ); - request - .tool_by_name("web", "run") - .context("Responses Lite should expose standalone web search")?; - request - .tool_by_name("image_gen", "imagegen") - .context("Responses Lite should expose standalone image generation")?; - let body = request.body_json(); - let tools = body["tools"] - .as_array() - .context("Responses request tools should be an array")?; + assert!(body.get("tools").is_none()); + let tools = additional_tools(&body)?; + assert!(!tools.is_empty()); assert!(!has_hosted_tool(tools, "web_search")); assert!(!has_hosted_tool(tools, "image_generation")); @@ -256,9 +313,8 @@ async fn responses_lite_omits_hosted_tools_without_standalone_extensions() -> Re test.submit_turn("Do not use hosted tools").await?; let body = response_mock.single_request().body_json(); - let tools = body["tools"] - .as_array() - .context("Responses request tools should be an array")?; + assert!(body.get("tools").is_none()); + let tools = additional_tools(&body)?; assert!(!has_hosted_tool(tools, "web_search")); assert!(!has_hosted_tool(tools, "image_generation")); diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 3e2cfd179..d4451a892 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -256,7 +256,8 @@ fn recent_images(history: &[ResponseItem], count: usize) -> Vec { ResponseItem::CustomToolCall { call_id, .. } => { custom_tool_call_ids.insert(call_id.as_str()); } - ResponseItem::Message { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Message { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } @@ -296,7 +297,8 @@ fn recent_images(history: &[ResponseItem], count: usize) -> Vec { ResponseItem::ImageGenerationCall { result, .. } if !result.is_empty() => { image_urls.push(format!("data:image/png;base64,{result}")); } - ResponseItem::Reasoning { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 02bd39714..7e2700236 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1198,6 +1198,7 @@ impl SessionTelemetry { fn responses_item_type(item: &ResponseItem) -> String { match item { + ResponseItem::AdditionalTools { .. } => "additional_tools".into(), ResponseItem::Message { role, .. } => format!("message_from_{role}"), ResponseItem::AgentMessage { .. } => "agent_message".into(), ResponseItem::Reasoning { .. } => "reasoning".into(), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index e107013a7..2faad8734 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -932,6 +932,14 @@ impl InternalChatMessageMetadataPassthrough { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { + #[schemars(skip)] + #[ts(skip)] + AdditionalTools { + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + role: String, + tools: Vec, + }, Message { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1160,7 +1168,8 @@ impl ResponseItem { /// Returns the non-empty Responses API item ID, if present. pub fn id(&self) -> Option<&str> { match self { - Self::Message { id, .. } + Self::AdditionalTools { id, .. } + | Self::Message { id, .. } | Self::AgentMessage { id, .. } | Self::LocalShellCall { id, .. } | Self::FunctionCall { id, .. } @@ -1181,7 +1190,8 @@ impl ResponseItem { /// Sets or clears the Responses API item ID for variants that carry one. pub fn set_id(&mut self, new_id: Option) { match self { - Self::Message { id, .. } + Self::AdditionalTools { id, .. } + | Self::Message { id, .. } | Self::AgentMessage { id, .. } | Self::LocalShellCall { id, .. } | Self::FunctionCall { id, .. } @@ -1282,7 +1292,7 @@ impl ResponseItem { internal_chat_message_metadata_passthrough: metadata, .. } => metadata.as_ref(), - Self::CompactionTrigger { .. } | Self::Other => None, + Self::CompactionTrigger { .. } | Self::AdditionalTools { .. } | Self::Other => None, } } @@ -1346,7 +1356,7 @@ impl ResponseItem { internal_chat_message_metadata_passthrough: metadata, .. } => Some(metadata), - Self::CompactionTrigger { .. } | Self::Other => None, + Self::CompactionTrigger { .. } | Self::AdditionalTools { .. } | Self::Other => None, } } } @@ -2237,6 +2247,14 @@ mod tests { item.set_id(/*new_id*/ None); assert_eq!(item.id(), None); + + let mut additional_tools = ResponseItem::AdditionalTools { + id: None, + role: "developer".to_string(), + tools: Vec::new(), + }; + additional_tools.set_id(Some("at_test".to_string())); + assert_eq!(additional_tools.id(), Some("at_test")); } fn response_item_with_passthrough_metadata( diff --git a/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs b/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs index e7a3253f6..f97cc65f6 100644 --- a/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs +++ b/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs @@ -37,6 +37,9 @@ pub(super) fn normalize_model_items( ) -> Result> { let mut normalized_items = Vec::new(); for item in items { + if item.get("type").and_then(Value::as_str) == Some("additional_tools") { + continue; + } normalized_items.push(normalize_model_item(item, raw_payload)?); } Ok(normalized_items) diff --git a/codex-rs/rollout-trace/src/reducer/conversation_tests.rs b/codex-rs/rollout-trace/src/reducer/conversation_tests.rs index 1cf13b1fa..18d38a417 100644 --- a/codex-rs/rollout-trace/src/reducer/conversation_tests.rs +++ b/codex-rs/rollout-trace/src/reducer/conversation_tests.rs @@ -735,6 +735,48 @@ fn unsupported_model_item_is_reducer_error() -> anyhow::Result<()> { ) } +#[test] +fn additional_tools_are_excluded_from_request_conversation() -> anyhow::Result<()> { + let temp = TempDir::new()?; + let writer = create_started_writer(&temp)?; + start_turn(&writer, "turn-1")?; + + let request = writer.write_json_payload( + RawPayloadKind::InferenceRequest, + &json!({ + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [{ + "type": "function", + "name": "lookup", + "parameters": {"type": "object", "properties": {}} + }] + }, + message("user", "find it") + ] + }), + )?; + append_inference_start(&writer, "inference-1", "turn-1", request)?; + + let rollout = replay_bundle(temp.path())?; + let request_item_ids = &rollout.inference_calls["inference-1"].request_item_ids; + + assert_eq!(request_item_ids.len(), 1); + assert_eq!(rollout.conversation_items.len(), 1); + assert_eq!( + rollout.conversation_items[&request_item_ids[0]].body, + ConversationBody { + parts: vec![ConversationPart::Text { + text: "find it".to_string(), + }], + } + ); + + Ok(()) +} + #[test] fn missing_request_input_is_reducer_error() -> anyhow::Result<()> { let temp = TempDir::new()?; diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index b342d699b..4db26576b 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -44,8 +44,9 @@ pub fn should_persist_response_item(item: &ResponseItem) -> bool { | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, - ResponseItem::CompactionTrigger { .. } => false, - ResponseItem::Other => false, + ResponseItem::AdditionalTools { .. } + | ResponseItem::CompactionTrigger { .. } + | ResponseItem::Other => false, } } @@ -62,7 +63,8 @@ pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } => true, - ResponseItem::AgentMessage { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. }