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
@@ -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);