mirror of
https://github.com/openai/codex.git
synced 2026-10-26 16:00:02 +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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user