From ceb4bc72c41e333bbafee14aa992e49ba2e953ed Mon Sep 17 00:00:00 2001 From: ningyi-oai Date: Thu, 30 Jul 2026 16:11:10 +0000 Subject: [PATCH] Record attempted tool calls in response metadata (#36181) ## What changed - Add the opt-in `executed_tool_call_metadata` feature to record model-attempted direct and code-mode tool calls. - Attach recorded names and arguments to the matching tool output in the next Responses request, preserving the metadata across sampling retries. - Bound pending calls and argument sizes, and emit truncation metadata when limits are exceeded. ## Testing - Cover disabled-by-default behavior, namespaced and nested calls, blocked or failed attempts, retry handling, and recorder limits. GitOrigin-RevId: 5ce917b73e797b0f6904d46477b9d2cf8ea71bf2 --- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/session/session.rs | 5 + codex-rs/core/src/session/tests.rs | 10 + codex-rs/core/src/session/turn.rs | 8 + codex-rs/core/src/state/mod.rs | 1 + codex-rs/core/src/state/service.rs | 2 + .../src/tools/code_mode/execute_handler.rs | 3 + .../core/src/tools/code_mode/wait_handler.rs | 6 + .../core/src/tools/executed_tool_calls.rs | 266 ++++++++++++++++++ .../src/tools/executed_tool_calls_tests.rs | 113 ++++++++ codex-rs/core/src/tools/mod.rs | 2 + codex-rs/core/src/tools/parallel.rs | 14 + codex-rs/core/tests/suite/code_mode.rs | 15 + codex-rs/core/tests/suite/hooks_mcp.rs | 9 + codex-rs/core/tests/suite/tools.rs | 102 +++++++ codex-rs/features/src/lib.rs | 8 + 16 files changed, 570 insertions(+) create mode 100644 codex-rs/core/src/tools/executed_tool_calls.rs create mode 100644 codex-rs/core/src/tools/executed_tool_calls_tests.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 7edb01a195..be0a862874 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -531,6 +531,9 @@ "exec_permission_approvals": { "type": "boolean" }, + "executed_tool_call_metadata": { + "type": "boolean" + }, "executor_capability_discovery": { "type": "boolean" }, @@ -5091,6 +5094,9 @@ "exec_permission_approvals": { "type": "boolean" }, + "executed_tool_call_metadata": { + "type": "boolean" + }, "executor_capability_discovery": { "type": "boolean" }, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 1849c33bd4..87a9204bf4 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1082,6 +1082,10 @@ impl Session { }).await; } + let executed_tool_calls = config + .features + .enabled(Feature::ExecutedToolCallMetadata) + .then(|| Arc::new(crate::state::ExecutedToolCallRecorder::default())); let services = SessionServices { // Start with an empty connection set. The initialized set is // published after SessionConfigured so MCP events follow it. @@ -1159,6 +1163,7 @@ impl Session { session_configuration.parent_thread_id, ), ), + executed_tool_calls, code_mode_service: crate::tools::code_mode::CodeModeService::new( Arc::clone(&code_mode_session_provider), &config.features, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b33c799224..d0378c4bf1 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5718,6 +5718,10 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { )); let network_approval = Arc::new(NetworkApprovalService::default()); let mcp_runtime = Arc::new(codex_mcp::McpRuntime::empty(config.prefix_mcp_tool_names())); + let executed_tool_calls = config + .features + .enabled(Feature::ExecutedToolCallMetadata) + .then(|| Arc::new(crate::state::ExecutedToolCallRecorder::default())); let services = SessionServices { mcp_runtime, unified_exec_manager: UnifiedExecProcessManager::new( @@ -5793,6 +5797,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { /*attestation_provider*/ None, config.http_client_factory(), ), + executed_tool_calls, code_mode_service: crate::tools::code_mode::CodeModeService::new( Arc::new(codex_code_mode::InProcessCodeModeSessionProvider), &config.features, @@ -7901,6 +7906,10 @@ where )); let network_approval = Arc::new(NetworkApprovalService::default()); let mcp_runtime = Arc::new(codex_mcp::McpRuntime::empty(config.prefix_mcp_tool_names())); + let executed_tool_calls = config + .features + .enabled(Feature::ExecutedToolCallMetadata) + .then(|| Arc::new(crate::state::ExecutedToolCallRecorder::default())); let services = SessionServices { mcp_runtime, unified_exec_manager: UnifiedExecProcessManager::new( @@ -7976,6 +7985,7 @@ where /*attestation_provider*/ None, config.http_client_factory(), ), + executed_tool_calls, code_mode_service: crate::tools::code_mode::CodeModeService::new( Arc::new(codex_code_mode::InProcessCodeModeSessionProvider), &config.features, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 1f16975fc8..2a51f892e3 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1336,6 +1336,7 @@ async fn run_sampling_request( let mut retries = 0; let mut initial_input = Some(input); let mut original_input = None; + let mut executed_tool_calls_by_output = HashMap::new(); loop { let prompt_input = if let Some(input) = initial_input.take() { input @@ -1344,6 +1345,13 @@ async fn run_sampling_request( .await .for_prompt(&turn_context.model_info.input_modalities) }; + let mut prompt_input = prompt_input; + if let Some(executed_tool_calls) = sess.services.executed_tool_calls.as_ref() + && executed_tool_calls + .attach_pending_to_prompt(&mut prompt_input, &mut executed_tool_calls_by_output) + { + codex_protocol::models::bound_executed_tool_calls_for_prompt(&mut prompt_input); + } let prompt = build_prompt( prompt_input, router.as_ref(), diff --git a/codex-rs/core/src/state/mod.rs b/codex-rs/core/src/state/mod.rs index 20ff78b54f..16297ea417 100644 --- a/codex-rs/core/src/state/mod.rs +++ b/codex-rs/core/src/state/mod.rs @@ -4,6 +4,7 @@ mod service; mod session; mod turn; +pub(crate) use crate::tools::ExecutedToolCallRecorder; pub(crate) use additional_context::AdditionalContextStore; pub(crate) use auto_compact_window::AutoCompactWindowIds; pub(crate) use auto_compact_window::AutoCompactWindowSnapshot; diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 463abea8af..6169fe074c 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -15,6 +15,7 @@ use crate::environment_selection::ThreadEnvironments; use crate::exec_policy::ExecPolicyManager; use crate::guardian::GuardianRejectionCircuitBreaker; use crate::mcp::McpManager; +use crate::tools::ExecutedToolCallRecorder; use crate::tools::code_mode::CodeModeService; use crate::tools::handlers::ToolSearchHandlerCache; use crate::tools::network_approval::NetworkApprovalService; @@ -88,6 +89,7 @@ pub(crate) struct SessionServices { pub(crate) time_provider: Arc, /// Session-scoped model client shared across turns. pub(crate) model_client: ModelClient, + pub(crate) executed_tool_calls: Option>, pub(crate) code_mode_service: CodeModeService, pub(crate) tool_search_handler_cache: ToolSearchHandlerCache, pub(crate) turn_environments: Arc, diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index 8374ae1ca4..ffc9e43feb 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -53,6 +53,9 @@ impl CodeModeExecuteHandler { .await .map_err(FunctionCallError::RespondToModel)?; let cell_id = started_cell.cell_id.clone(); + if let Some(executed_tool_calls) = exec.session.services.executed_tool_calls.as_ref() { + executed_tool_calls.register_cell(&cell_id, &call_id); + } let runtime_cell_id = cell_id.to_string(); let code_cell_trace = exec .session diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index c3c867f3ef..d128850a23 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -66,6 +66,7 @@ impl CodeModeWaitHandler { let ToolInvocation { session, turn, + call_id, tool_name, payload, .. @@ -79,6 +80,11 @@ impl CodeModeWaitHandler { let exec = ExecContext { session, turn }; let started_at = std::time::Instant::now(); let cell_id = codex_code_mode::CellId::new(args.cell_id); + if let Some(executed_tool_calls) = + exec.session.services.executed_tool_calls.as_ref() + { + executed_tool_calls.register_cell(&cell_id, &call_id); + } let wait_response = if args.terminate { exec.session .services diff --git a/codex-rs/core/src/tools/executed_tool_calls.rs b/codex-rs/core/src/tools/executed_tool_calls.rs new file mode 100644 index 0000000000..4465626db0 --- /dev/null +++ b/codex-rs/core/src/tools/executed_tool_calls.rs @@ -0,0 +1,266 @@ +use std::collections::HashMap; +use std::collections::HashSet; + +use codex_code_mode::CellId; +use codex_protocol::models::ExecutedToolCall; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ToolMode; +use serde_json::Value as JsonValue; + +use crate::tools::context::ToolCallSource; +use crate::tools::context::ToolPayload; +use crate::tools::router::ToolCall; + +const MAX_EXECUTED_TOOL_CALL_ARGUMENT_BYTES: usize = 8 * 1024; +const MAX_EXECUTED_TOOL_CALL_FULL_ARGUMENT_BYTES_PER_OUTPUT: usize = 32 * 1024; +const MAX_PENDING_EXECUTED_TOOL_CALLS: usize = 256; + +/// Best-effort, session-scoped attempted-tool metadata; cancellation, compaction, +/// and yielded cells without another wait can leave pending calls unreported. +#[derive(Default)] +pub(crate) struct ExecutedToolCallRecorder { + state: std::sync::Mutex, +} + +#[derive(Default)] +struct ExecutedToolCallRecorderState { + direct_calls: HashMap, + cells: HashMap, + output_cells: HashMap, + pending_nested_calls: usize, +} + +#[derive(Default)] +struct RecordedCell { + pending_calls: Vec, + pending_full_argument_bytes: usize, +} + +#[derive(Default)] +struct JsonByteCounter(usize); + +impl std::io::Write for JsonByteCounter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 = self.0.saturating_add(bytes.len()); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn serialized_json_bytes(value: &T) -> usize { + let mut counter = JsonByteCounter::default(); + if serde_json::to_writer(&mut counter, value).is_err() { + return usize::MAX; + } + counter.0 +} + +impl ExecutedToolCallRecorder { + pub(crate) fn record_tool_call( + &self, + call: &ToolCall, + source: &ToolCallSource, + tool_mode: ToolMode, + ) { + if matches!(source, ToolCallSource::Direct) + && matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) + && call.tool_name.namespace.is_none() + && matches!( + (call.tool_name.name.as_str(), &call.payload), + ( + crate::tools::code_mode::PUBLIC_TOOL_NAME, + ToolPayload::Custom { .. } + ) | ( + crate::tools::code_mode::WAIT_TOOL_NAME, + ToolPayload::Function { .. } + ) + ) + { + return; + } + + let original_bytes = match &call.payload { + ToolPayload::Function { arguments } => arguments.len(), + ToolPayload::Custom { input } => serialized_json_bytes(input), + ToolPayload::ToolSearch { arguments } => serialized_json_bytes(arguments), + }; + let name = codex_tools::code_mode_name_for_tool_name(&call.tool_name); + let recorded_call = if original_bytes > MAX_EXECUTED_TOOL_CALL_ARGUMENT_BYTES { + ExecutedToolCall::truncated(name, original_bytes, MAX_EXECUTED_TOOL_CALL_ARGUMENT_BYTES) + } else { + let arguments = match &call.payload { + ToolPayload::Function { arguments } => serde_json::from_str(arguments) + .unwrap_or_else(|_| JsonValue::String(arguments.clone())), + ToolPayload::Custom { input } => JsonValue::String(input.clone()), + ToolPayload::ToolSearch { arguments } => { + serde_json::to_value(arguments).unwrap_or_default() + } + }; + ExecutedToolCall::new(name, arguments) + }; + match source { + ToolCallSource::Direct | ToolCallSource::DirectPlaintextMessage => { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.direct_calls.len() < MAX_PENDING_EXECUTED_TOOL_CALLS { + state + .direct_calls + .entry(call.call_id.clone()) + .or_insert(recorded_call); + } else if state.direct_calls.len() == MAX_PENDING_EXECUTED_TOOL_CALLS + && !state.direct_calls.contains_key(&call.call_id) + { + state.direct_calls.insert( + call.call_id.clone(), + ExecutedToolCall::truncated( + recorded_call.name, + original_bytes, + /*max_bytes*/ 0, + ), + ); + } + } + ToolCallSource::CodeMode { cell_id, .. } => { + self.record_nested_tool_call( + CellId::new(cell_id.clone()), + recorded_call, + original_bytes, + ); + } + } + } + + fn record_nested_tool_call( + &self, + cell_id: CellId, + call: ExecutedToolCall, + original_bytes: usize, + ) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.pending_nested_calls > MAX_PENDING_EXECUTED_TOOL_CALLS + || (state.cells.len() >= MAX_PENDING_EXECUTED_TOOL_CALLS + && !state.cells.contains_key(&cell_id)) + { + return; + } + let at_pending_call_limit = state.pending_nested_calls == MAX_PENDING_EXECUTED_TOOL_CALLS; + let cell = state.cells.entry(cell_id).or_default(); + let max_bytes = MAX_EXECUTED_TOOL_CALL_ARGUMENT_BYTES.min( + MAX_EXECUTED_TOOL_CALL_FULL_ARGUMENT_BYTES_PER_OUTPUT + .saturating_sub(cell.pending_full_argument_bytes), + ); + let call = if at_pending_call_limit { + ExecutedToolCall::truncated(call.name, original_bytes, /*max_bytes*/ 0) + } else if original_bytes <= max_bytes { + cell.pending_full_argument_bytes = cell + .pending_full_argument_bytes + .saturating_add(original_bytes); + call + } else { + ExecutedToolCall::truncated(call.name, original_bytes, max_bytes) + }; + cell.pending_calls.push(call); + state.pending_nested_calls += 1; + } + + pub(crate) fn attach_pending_to_prompt( + &self, + items: &mut [ResponseItem], + retry_cache: &mut HashMap< + (std::mem::Discriminant, String), + Vec, + >, + ) -> bool { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.direct_calls.is_empty() && state.output_cells.is_empty() && retry_cache.is_empty() + { + return false; + } + + let mut pending_retry_outputs = retry_cache.keys().cloned().collect::>(); + let mut attached = false; + for item in items.iter_mut().rev() { + if state.direct_calls.is_empty() + && state.output_cells.is_empty() + && pending_retry_outputs.is_empty() + { + break; + } + let call_id = match &*item { + ResponseItem::FunctionCallOutput { call_id, .. } + | ResponseItem::CustomToolCallOutput { call_id, .. } + | ResponseItem::ToolSearchOutput { + call_id: Some(call_id), + .. + } => call_id, + _ => continue, + }; + let key = (std::mem::discriminant(&*item), call_id.clone()); + let calls = if let Some(cached) = retry_cache.get(&key) { + if !pending_retry_outputs.remove(&key) { + continue; + } + cached.clone() + } else { + let mut calls = state + .direct_calls + .remove(call_id) + .into_iter() + .collect::>(); + if let Some(cell_id) = state.output_cells.remove(call_id) + && let Some(mut cell) = state.cells.remove(&cell_id) + { + state.pending_nested_calls = state + .pending_nested_calls + .saturating_sub(cell.pending_calls.len()); + state + .output_cells + .retain(|_, output_cell_id| output_cell_id != &cell_id); + calls.append(&mut cell.pending_calls); + } + if calls.is_empty() { + continue; + } + retry_cache.insert(key, calls.clone()); + calls + }; + item.append_executed_tool_calls(calls); + attached = true; + } + + attached + } + + pub(crate) fn register_cell(&self, cell_id: &CellId, output_call_id: &str) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if (state.cells.len() >= MAX_PENDING_EXECUTED_TOOL_CALLS + && !state.cells.contains_key(cell_id)) + || (state.output_cells.len() >= MAX_PENDING_EXECUTED_TOOL_CALLS + && !state.output_cells.contains_key(output_call_id)) + { + return; + } + state.cells.entry(cell_id.clone()).or_default(); + state + .output_cells + .insert(output_call_id.to_string(), cell_id.clone()); + } +} + +#[cfg(test)] +#[path = "executed_tool_calls_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/executed_tool_calls_tests.rs b/codex-rs/core/src/tools/executed_tool_calls_tests.rs new file mode 100644 index 0000000000..d22508178e --- /dev/null +++ b/codex-rs/core/src/tools/executed_tool_calls_tests.rs @@ -0,0 +1,113 @@ +use codex_protocol::models::FunctionCallOutputPayload; +use pretty_assertions::assert_eq; +use serde_json::json; + +use super::*; + +#[test] +fn executed_tool_call_recorder_bounds_pending_calls_and_preserves_overflow() { + let recorder = ExecutedToolCallRecorder::default(); + + for index in 0..MAX_PENDING_EXECUTED_TOOL_CALLS + 2 { + recorder.record_tool_call( + &ToolCall { + tool_name: codex_tools::ToolName::plain("direct_tool"), + call_id: format!("direct-{index}"), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + encrypted_function_args: None, + }, + &ToolCallSource::Direct, + ToolMode::Direct, + ); + } + + let cell_id = CellId::new("bounded-cell".to_string()); + recorder.register_cell(&cell_id, "bounded-output"); + for _ in 0..MAX_PENDING_EXECUTED_TOOL_CALLS + 2 { + recorder.record_nested_tool_call( + cell_id.clone(), + ExecutedToolCall::new("nested_tool".to_string(), json!({})), + /*original_bytes*/ 2, + ); + } + + for index in 0..MAX_PENDING_EXECUTED_TOOL_CALLS + 2 { + recorder.register_cell( + &CellId::new(format!("cell-{index}")), + &format!("output-{index}"), + ); + } + + { + let state = recorder + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!( + state.direct_calls.len(), + MAX_PENDING_EXECUTED_TOOL_CALLS + 1 + ); + assert_eq!( + serde_json::to_value( + state + .direct_calls + .get(&format!("direct-{MAX_PENDING_EXECUTED_TOOL_CALLS}")) + .expect("first excess direct call must be marked"), + ) + .expect("direct overflow marker must serialize"), + json!({ + "name": "direct_tool", + "arguments": { + "_codex_executed_tool_call_truncated": { + "original_bytes": 2, + "max_bytes": 0, + }, + }, + }), + ); + assert_eq!( + state.pending_nested_calls, + MAX_PENDING_EXECUTED_TOOL_CALLS + 1 + ); + assert_eq!(state.cells.len(), MAX_PENDING_EXECUTED_TOOL_CALLS); + assert_eq!(state.output_cells.len(), MAX_PENDING_EXECUTED_TOOL_CALLS); + } + + let mut items = [ResponseItem::FunctionCallOutput { + id: None, + call_id: "bounded-output".to_string(), + output: FunctionCallOutputPayload::from_text(String::new()), + internal_chat_message_metadata_passthrough: None, + }]; + let mut retry_cache = HashMap::new(); + recorder.attach_pending_to_prompt(&mut items, &mut retry_cache); + + let calls = items[0] + .executed_tool_call_metadata() + .and_then(|metadata| metadata.executed_tool_calls.as_ref()) + .expect("bounded nested calls must attach to their own output"); + assert_eq!(calls.len(), MAX_PENDING_EXECUTED_TOOL_CALLS + 1); + assert_eq!( + serde_json::to_value(calls.last().expect("overflow marker must be retained")) + .expect("nested overflow marker must serialize"), + json!({ + "name": "nested_tool", + "arguments": { + "_codex_executed_tool_call_truncated": { + "original_bytes": 2, + "max_bytes": 0, + }, + }, + }), + ); + + let state = recorder + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(state.pending_nested_calls, 0); + assert!(!state.cells.contains_key(&cell_id)); + assert_eq!(retry_cache.len(), 1); +} diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 76a836def3..17cf4da5fb 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -2,6 +2,7 @@ mod approvals; pub(crate) mod code_mode; pub(crate) mod context; pub(crate) mod events; +mod executed_tool_calls; pub(crate) mod handlers; pub(crate) mod hook_names; pub(crate) mod hosted_spec; @@ -26,6 +27,7 @@ use codex_tools::ToolName; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::formatted_truncate_text; use codex_utils_output_truncation::truncate_text; +pub(crate) use executed_tool_calls::ExecutedToolCallRecorder; pub use router::ToolRouter; // Telemetry preview limits: keep log events smaller than model budgets. diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 0dd5bc6f45..5cc896341f 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -97,6 +97,20 @@ impl ToolCallRuntime { source: ToolCallSource, cancellation_token: CancellationToken, ) -> impl std::future::Future> { + if self + .step_context + .turn + .config + .features + .enabled(codex_features::Feature::ExecutedToolCallMetadata) + && let Some(executed_tool_calls) = self.session.services.executed_tool_calls.as_ref() + { + executed_tool_calls.record_tool_call( + &call, + &source, + super::effective_tool_mode(&self.step_context.turn), + ); + } let supports_parallel = self.router.tool_supports_parallel(&call); let tool_runtime = self.router.tool_runtime(&call); let router = Arc::clone(&self.router); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index e2ad59b171..190b0445d6 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -195,6 +195,7 @@ async fn run_code_mode_turn_with_model_and_config( ) -> Result<(TestCodex, ResponseMock)> { let builder = test_codex().with_model(model).with_config(move |config| { let _ = config.features.enable(Feature::CodeMode); + let _ = config.features.enable(Feature::ExecutedToolCallMetadata); configure(config); }); run_code_mode_turn_with_builder(server, prompt, code, builder).await @@ -985,6 +986,13 @@ text(output.output); "code_mode_only nested tool call failed unexpectedly: {output}" ); assert_eq!(output, "code_mode_only_nested_tool_marker"); + assert!( + request + .custom_tool_call_output("call-1") + .pointer("/internal_chat_message_metadata_passthrough/executed_tool_calls") + .is_none(), + "nested tool calls must not be recorded while executed-tool-call metadata is disabled", + ); Ok(()) } @@ -1581,6 +1589,13 @@ try { .await?; let request = second_mock.single_request(); + assert_eq!( + request.custom_tool_call_output("call-1")["internal_chat_message_metadata_passthrough"]["executed_tool_calls"], + serde_json::json!([ + {"name": "exec_command", "arguments": {}}, + ]), + "failed nested tool attempts remain private request metadata", + ); let (output, success) = custom_tool_output_body_and_success(&request, "call-1"); assert_ne!( success, diff --git a/codex-rs/core/tests/suite/hooks_mcp.rs b/codex-rs/core/tests/suite/hooks_mcp.rs index 44c91a0d48..94839fd117 100644 --- a/codex-rs/core/tests/suite/hooks_mcp.rs +++ b/codex-rs/core/tests/suite/hooks_mcp.rs @@ -277,6 +277,7 @@ async fn pre_tool_use_blocks_mcp_tool_before_execution( .expect("failed to write MCP pre tool use hook fixture"); }) .with_config(move |config| { + let _ = config.features.enable(Feature::ExecutedToolCallMetadata); enable_hooks_and_rmcp_server( config, rmcp_test_server_bin, @@ -294,6 +295,14 @@ async fn pre_tool_use_blocks_mcp_tool_before_execution( let requests = responses.requests(); assert_eq!(requests.len(), 2); let output_item = requests[1].function_call_output(call_id); + assert_eq!( + output_item["internal_chat_message_metadata_passthrough"]["executed_tool_calls"], + json!([{ + "name": format!("{mcp_namespace}__echo"), + "arguments": { "message": RMCP_ECHO_MESSAGE }, + }]), + "a blocked MCP request must still retain the original model-attempted call", + ); let output = output_item .get("output") .and_then(Value::as_str) diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 9b9e4e99ba..f47fb8fc07 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -175,6 +175,11 @@ async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> { .unwrap_or_default(); let expected = format!("unsupported custom tool call: {tool_name}"); assert_eq!(output, expected); + assert!( + item.pointer("/internal_chat_message_metadata_passthrough/executed_tool_calls") + .is_none(), + "attempted-tool metadata must be disabled by default", + ); Ok(()) } @@ -186,6 +191,9 @@ async fn namespaced_custom_tool_call_preserves_namespace_through_dispatch_and_re let server = start_mock_server().await; let mut builder = test_codex(); + builder = builder.with_config(|config| { + let _ = config.features.enable(Feature::ExecutedToolCallMetadata); + }); let test = builder.build(&server).await?; let call_id = "custom-namespaced"; @@ -248,10 +256,104 @@ async fn namespaced_custom_tool_call_preserves_namespace_through_dispatch_and_re "output": format!("unsupported custom tool call: {namespace}{tool_name}"), "internal_chat_message_metadata_passthrough": { "turn_id": turn_id, + "executed_tool_calls": [{ + "name": format!("{namespace}__{tool_name}"), + "arguments": input, + }], }, }), ) ); + let escaped_call_id = "custom-namespaced-escaped"; + let escaped_input = "\\".repeat(4_096); + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-3"), + ev_custom_tool_call_with_namespace( + escaped_call_id, + namespace, + tool_name, + &escaped_input, + ), + ev_completed("resp-3"), + ]), + ) + .await; + let escaped_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-2", "done"), + ev_completed("resp-4"), + ]), + ) + .await; + test.submit_turn_with_approval_and_permission_profile( + "invoke namespaced custom tool with escaped arguments", + AskForApproval::Never, + PermissionProfile::Disabled, + ) + .await?; + assert_eq!( + escaped_mock + .single_request() + .custom_tool_call_output(escaped_call_id)["internal_chat_message_metadata_passthrough"] + ["executed_tool_calls"], + json!([{ + "name": format!("{namespace}__{tool_name}"), + "arguments": { + "_codex_executed_tool_call_truncated": { + "original_bytes": serde_json::to_vec(&escaped_input)?.len(), + "max_bytes": 8 * 1024, + }, + }, + }]), + ); + + let direct_exec_call_id = "custom-direct-exec"; + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-5"), + ev_custom_tool_call( + direct_exec_call_id, + codex_code_mode::PUBLIC_TOOL_NAME, + input, + ), + ev_completed("resp-5"), + ]), + ) + .await; + let direct_exec_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-3", "done"), + ev_completed("resp-6"), + ]), + ) + .await; + + test.submit_turn_with_approval_and_permission_profile( + "invoke direct custom exec outside code mode", + AskForApproval::Never, + PermissionProfile::Disabled, + ) + .await?; + + let direct_exec_output = direct_exec_mock + .single_request() + .custom_tool_call_output(direct_exec_call_id); + assert_eq!( + direct_exec_output["output"], + json!("unsupported custom tool call: exec"), + ); + assert_eq!( + direct_exec_output["internal_chat_message_metadata_passthrough"]["executed_tool_calls"], + json!([{ + "name": codex_code_mode::PUBLIC_TOOL_NAME, + "arguments": input, + }]), + ); Ok(()) } diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index fb7a420c68..627b241c64 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -92,6 +92,8 @@ pub enum Feature { SecretAuthStorage, // Experimental + /// Record model-attempted tool calls in internal Responses metadata. + ExecutedToolCallMetadata, /// Enable JavaScript code mode backed by the in-process V8 runtime. CodeMode, /// Use a 30-second default yield timeout for code mode exec calls. @@ -889,6 +891,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, + FeatureSpec { + id: Feature::ExecutedToolCallMetadata, + key: "executed_tool_call_metadata", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::CodeMode, key: "code_mode",