mirror of
https://github.com/openai/codex.git
synced 2026-10-21 12:10:06 +00:00
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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user