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
This commit is contained in:
ningyi-oai
2026-07-30 16:22:43 +00:00
committed by copyberry
parent 355d2a802a
commit ceb4bc72c4
16 changed files with 570 additions and 0 deletions
+6
View File
@@ -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"
},
+5
View File
@@ -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,
+10
View File
@@ -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,
+8
View File
@@ -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(),
+1
View File
@@ -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;
+2
View File
@@ -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<dyn TimeProvider>,
/// Session-scoped model client shared across turns.
pub(crate) model_client: ModelClient,
pub(crate) executed_tool_calls: Option<Arc<ExecutedToolCallRecorder>>,
pub(crate) code_mode_service: CodeModeService,
pub(crate) tool_search_handler_cache: ToolSearchHandlerCache,
pub(crate) turn_environments: Arc<ThreadEnvironments>,
@@ -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
@@ -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
@@ -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<ExecutedToolCallRecorderState>,
}
#[derive(Default)]
struct ExecutedToolCallRecorderState {
direct_calls: HashMap<String, ExecutedToolCall>,
cells: HashMap<CellId, RecordedCell>,
output_cells: HashMap<String, CellId>,
pending_nested_calls: usize,
}
#[derive(Default)]
struct RecordedCell {
pending_calls: Vec<ExecutedToolCall>,
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<usize> {
self.0 = self.0.saturating_add(bytes.len());
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn serialized_json_bytes<T: serde::Serialize + ?Sized>(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<ResponseItem>, String),
Vec<ExecutedToolCall>,
>,
) -> 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::<HashSet<_>>();
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::<Vec<_>>();
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;
@@ -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);
}
+2
View File
@@ -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.
+14
View File
@@ -97,6 +97,20 @@ impl ToolCallRuntime {
source: ToolCallSource,
cancellation_token: CancellationToken,
) -> impl std::future::Future<Output = Result<AnyToolResult, FunctionCallError>> {
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);
+15
View File
@@ -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,
+9
View File
@@ -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)
+102
View File
@@ -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(())
}
+8
View File
@@ -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",