mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
Add bounded tool-result metadata support to executed tool calls (#44336)
## What changed - Add host-recorded `tool_result_metadata` snapshots with size limits, redacted debug output, and protection against deserialization from untrusted input. MCP capture remains disabled. - Preserve accepted metadata across retries and delayed Code Mode results. Shed raw metadata before source evidence, calls, or completion markers when the request budget is exceeded. - Send raw metadata only to allowed OpenAI and ChatGPT HTTPS endpoints, filtering by the resolved destination for HTTP and WebSocket requests. - Encode source parse failures as an array entry with type `parse_failed` and an empty ID. ## Testing Add coverage for metadata bounds and redaction, retained-call updates, budget shedding, disabled MCP capture, and HTTP/WebSocket destination filtering. GitOrigin-RevId: b1a396323ad0623167cdd9e20ff9453f0d01bbf7
This commit is contained in:
@@ -44,6 +44,7 @@ pub use executed_tool_calls::ExecutedToolCall;
|
||||
pub use executed_tool_calls::ExecutedToolCallArguments;
|
||||
pub use executed_tool_calls::ExecutedToolCallTruncation;
|
||||
pub use executed_tool_calls::MAX_TOOL_RESULT_SOURCE_FIELD_BYTES;
|
||||
pub use executed_tool_calls::ToolResultMetadata;
|
||||
pub use executed_tool_calls::ToolResultSource;
|
||||
pub use executed_tool_calls::ToolResultSources;
|
||||
pub use executed_tool_calls::bound_executed_tool_calls_for_prompt;
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn executed_tool_call_metadata_bytes(item: &ResponseItem) -> usize {
|
||||
}
|
||||
|
||||
impl InternalChatMessageMetadataPassthrough {
|
||||
/// Compares call order, names and arguments, ignoring optional source metadata.
|
||||
/// Compares call order, names and arguments, ignoring optional result metadata.
|
||||
pub fn has_same_tool_calls(&self, calls: &[ExecutedToolCall]) -> bool {
|
||||
self.executed_tool_calls.as_ref().is_some_and(|recorded| {
|
||||
recorded.len() == calls.len()
|
||||
@@ -128,6 +128,35 @@ fn bound_executed_tool_calls_for_prompt_with_priority(
|
||||
original_metadata_bytes.saturating_add(executed_tool_call_metadata_bytes(item));
|
||||
}
|
||||
|
||||
// Raw result metadata must not displace existing source evidence, calls or completion proof.
|
||||
if original_metadata_bytes > MAX_EXECUTED_TOOL_CALL_METADATA_BYTES {
|
||||
original_metadata_bytes = 0;
|
||||
for item in items.iter_mut() {
|
||||
if let Some(metadata) = item
|
||||
.internal_chat_message_metadata_passthrough_mut()
|
||||
.and_then(Option::as_mut)
|
||||
{
|
||||
for call in metadata.executed_tool_calls.iter_mut().flatten() {
|
||||
if call.tool_result_metadata.is_some() {
|
||||
call.tool_result_metadata = ToolResultMetadata::omitted_due_to_size_limit();
|
||||
}
|
||||
}
|
||||
}
|
||||
original_metadata_bytes =
|
||||
original_metadata_bytes.saturating_add(executed_tool_call_metadata_bytes(item));
|
||||
}
|
||||
}
|
||||
|
||||
// Omission markers are optional too; keep the original call budget if they cannot fit.
|
||||
if original_metadata_bytes > MAX_EXECUTED_TOOL_CALL_METADATA_BYTES {
|
||||
original_metadata_bytes = 0;
|
||||
for item in items.iter_mut() {
|
||||
item.clear_tool_result_metadata();
|
||||
original_metadata_bytes =
|
||||
original_metadata_bytes.saturating_add(executed_tool_call_metadata_bytes(item));
|
||||
}
|
||||
}
|
||||
|
||||
// Source evidence is optional; dropping it must not discard calls or their completion proof.
|
||||
if original_metadata_bytes > MAX_EXECUTED_TOOL_CALL_METADATA_BYTES {
|
||||
original_metadata_bytes = 0;
|
||||
@@ -336,24 +365,81 @@ pub struct ExecutedToolCall {
|
||||
#[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(skip)]
|
||||
#[ts(skip)]
|
||||
tool_result_sources: Option<ToolResultSourcesValue>,
|
||||
tool_result_sources: Option<Vec<ToolResultSource>>,
|
||||
/// Raw MCP result metadata is host-recorded only, never trusted from input JSON or exposed
|
||||
/// in public schemas. Its Debug implementation also prevents raw values reaching logs.
|
||||
#[serde(
|
||||
default,
|
||||
skip_deserializing,
|
||||
skip_serializing_if = "ToolResultMetadata::is_none"
|
||||
)]
|
||||
#[schemars(skip)]
|
||||
#[ts(skip)]
|
||||
tool_result_metadata: ToolResultMetadata,
|
||||
}
|
||||
|
||||
/// An entire MCP result's `_meta`, or a string marker when omitted due to the size limit.
|
||||
#[derive(Clone, Default, Serialize, PartialEq, Eq)]
|
||||
#[serde(transparent)]
|
||||
pub struct ToolResultMetadata(Option<serde_json::Value>);
|
||||
|
||||
impl std::fmt::Debug for ToolResultMetadata {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("ToolResultMetadata([redacted])")
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolResultMetadata {
|
||||
/// Bounds serialization before cloning arbitrary MCP metadata; no keys are filtered.
|
||||
pub fn new(metadata: &serde_json::Value) -> Self {
|
||||
let mut limit = MetadataSizeLimit(MAX_EXECUTED_TOOL_CALL_METADATA_BYTES);
|
||||
if serde_json::to_writer(&mut limit, metadata).is_ok() {
|
||||
Self(Some(metadata.clone()))
|
||||
} else {
|
||||
Self::omitted_due_to_size_limit()
|
||||
}
|
||||
}
|
||||
|
||||
fn omitted_due_to_size_limit() -> Self {
|
||||
// MCP `_meta` is an object; this string is a harness omission status, not provider data.
|
||||
Self(Some(serde_json::Value::String(
|
||||
"omitted_due_to_size_limit".to_string(),
|
||||
)))
|
||||
}
|
||||
|
||||
fn is_none(&self) -> bool {
|
||||
self.0.is_none()
|
||||
}
|
||||
|
||||
/// Whether the bounded snapshot contains metadata or an omission marker.
|
||||
pub fn is_some(&self) -> bool {
|
||||
self.0.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
struct MetadataSizeLimit(usize);
|
||||
|
||||
impl std::io::Write for MetadataSizeLimit {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.0 = self
|
||||
.0
|
||||
.checked_sub(bytes.len())
|
||||
.ok_or_else(|| std::io::Error::other("tool result metadata exceeds the byte limit"))?;
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded capture update. Omitted updates still clear any previously recorded evidence.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolResultSources(Option<ToolResultSourcesValue>);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
enum ToolResultSourcesValue {
|
||||
#[serde(rename = "parse_failed")]
|
||||
ParseFailed,
|
||||
// Preserve the existing array shape for successful captures, including an empty array.
|
||||
#[serde(untagged)]
|
||||
Sources(Vec<ToolResultSource>),
|
||||
}
|
||||
pub struct ToolResultSources(Option<Vec<ToolResultSource>>);
|
||||
|
||||
impl ToolResultSources {
|
||||
/// Deduplicates a complete source list, discarding all sources if it exceeds a limit.
|
||||
/// Deduplicates a captured snapshot, discarding all sources if it exceeds a limit.
|
||||
/// Capture rules determine coverage; this is not a resource or permission inventory.
|
||||
pub fn new(sources: Vec<ToolResultSource>) -> Self {
|
||||
let mut unique_sources = Vec::new();
|
||||
for source in sources {
|
||||
@@ -368,16 +454,20 @@ impl ToolResultSources {
|
||||
}
|
||||
unique_sources.push(source);
|
||||
}
|
||||
Self(Some(ToolResultSourcesValue::Sources(unique_sources)))
|
||||
Self(Some(unique_sources))
|
||||
}
|
||||
|
||||
/// Records that source parsing was attempted but failed, not that a budget was exceeded.
|
||||
/// Records a failed parse using the receiver's existing array-of-sources shape.
|
||||
/// `parse_failed` is a status marker, not a resource type; the required ID is empty.
|
||||
pub fn parse_failed() -> Self {
|
||||
Self(Some(ToolResultSourcesValue::ParseFailed))
|
||||
Self(Some(vec![ToolResultSource {
|
||||
r#type: "parse_failed".to_string(),
|
||||
id: String::new(),
|
||||
}]))
|
||||
}
|
||||
}
|
||||
|
||||
/// A trusted source identity observed in an accepted tool result by the host.
|
||||
/// A captured source ID, or a `parse_failed` status marker with an empty ID.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct ToolResultSource {
|
||||
#[serde(rename = "type")]
|
||||
@@ -411,6 +501,7 @@ impl ExecutedToolCall {
|
||||
name,
|
||||
arguments: ExecutedToolCallArguments::Raw(arguments),
|
||||
tool_result_sources: None,
|
||||
tool_result_metadata: ToolResultMetadata::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +523,11 @@ impl ExecutedToolCall {
|
||||
self.tool_result_sources.is_some()
|
||||
}
|
||||
|
||||
/// Replaces the entire `_meta` snapshot, including with an omission marker if oversized.
|
||||
pub fn set_tool_result_metadata(&mut self, metadata: ToolResultMetadata) {
|
||||
self.tool_result_metadata = metadata;
|
||||
}
|
||||
|
||||
fn truncation(&self) -> Option<&ExecutedToolCallTruncation> {
|
||||
match &self.arguments {
|
||||
ExecutedToolCallArguments::Raw(_) => None,
|
||||
@@ -510,6 +606,18 @@ impl ResponseItem {
|
||||
self.internal_chat_message_metadata_passthrough()
|
||||
}
|
||||
|
||||
/// Omits raw tool results without changing existing call, source or completion metadata.
|
||||
pub fn clear_tool_result_metadata(&mut self) {
|
||||
if let Some(metadata) = self
|
||||
.internal_chat_message_metadata_passthrough_mut()
|
||||
.and_then(Option::as_mut)
|
||||
{
|
||||
for call in metadata.executed_tool_calls.iter_mut().flatten() {
|
||||
call.tool_result_metadata = ToolResultMetadata::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds one request item's attempted calls to its share of the prompt budget.
|
||||
fn bound_executed_tool_calls_with_budget(&mut self, max_metadata_bytes: usize) {
|
||||
let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() else {
|
||||
|
||||
@@ -267,7 +267,7 @@ fn tool_call_completeness_is_host_only_and_fail_closed() -> Result<()> {
|
||||
assert_eq!(untrusted, passthrough_metadata("turn-1"));
|
||||
for sources in [
|
||||
serde_json::json!([{ "type": "test_resource", "id": "ATTACKER" }]),
|
||||
serde_json::json!("parse_failed"),
|
||||
serde_json::json!([{ "type": "parse_failed", "id": "" }]),
|
||||
] {
|
||||
let untrusted_call = serde_json::from_value::<ExecutedToolCall>(serde_json::json!({
|
||||
"name": "test_tool",
|
||||
@@ -334,10 +334,7 @@ fn tool_result_source_snapshots_replace_atomically() -> Result<()> {
|
||||
sources.push(sources[0].clone());
|
||||
let capture = ToolResultSources::new(sources.clone());
|
||||
sources.truncate(MAX_TOOL_RESULT_SOURCES);
|
||||
assert_eq!(
|
||||
capture,
|
||||
ToolResultSources(Some(ToolResultSourcesValue::Sources(sources.clone())))
|
||||
);
|
||||
assert_eq!(capture, ToolResultSources(Some(sources.clone())));
|
||||
assert!(call.set_tool_result_sources(capture));
|
||||
sources.push(source("test_resource", "OVERFLOW"));
|
||||
let capture = ToolResultSources::new(sources);
|
||||
@@ -361,10 +358,7 @@ fn tool_result_source_snapshots_replace_atomically() -> Result<()> {
|
||||
source("test_resource", &oversized),
|
||||
] {
|
||||
assert!(call.set_tool_result_sources(ToolResultSources::new(vec![bounded.clone()])));
|
||||
assert_eq!(
|
||||
call.tool_result_sources,
|
||||
Some(ToolResultSourcesValue::Sources(vec![bounded.clone()]))
|
||||
);
|
||||
assert_eq!(call.tool_result_sources, Some(vec![bounded.clone()]));
|
||||
let capture = ToolResultSources::new(vec![source("test_resource", "R1"), invalid]);
|
||||
assert_eq!(capture, ToolResultSources(None));
|
||||
assert!(!call.set_tool_result_sources(capture));
|
||||
@@ -378,7 +372,7 @@ fn tool_result_source_snapshots_replace_atomically() -> Result<()> {
|
||||
serde_json::json!({
|
||||
"name": "test_tool",
|
||||
"arguments": {},
|
||||
"tool_result_sources": "parse_failed",
|
||||
"tool_result_sources": [{ "type": "parse_failed", "id": "" }],
|
||||
})
|
||||
);
|
||||
assert!(call.set_tool_result_sources(ToolResultSources::new(Vec::new())));
|
||||
@@ -388,3 +382,161 @@ fn tool_result_source_snapshots_replace_atomically() -> Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_metadata_is_host_only_bounded_and_redacted() -> Result<()> {
|
||||
let mut call = ExecutedToolCall::new("test_tool".to_string(), serde_json::json!({}));
|
||||
let without_metadata = call.clone();
|
||||
for metadata in [
|
||||
serde_json::json!({
|
||||
"arbitrary-key": { "secret": "not-for-debug", "ids": [1, "é", null] },
|
||||
"other": false,
|
||||
}),
|
||||
serde_json::json!({}),
|
||||
serde_json::json!(null),
|
||||
] {
|
||||
let capture = ToolResultMetadata::new(&metadata);
|
||||
assert_eq!(format!("{capture:?}"), "ToolResultMetadata([redacted])");
|
||||
assert!(capture.is_some());
|
||||
call.set_tool_result_metadata(capture);
|
||||
let wire = serde_json::to_value(&call)?;
|
||||
assert_eq!(
|
||||
wire,
|
||||
serde_json::json!({
|
||||
"name": "test_tool",
|
||||
"arguments": {},
|
||||
"tool_result_metadata": metadata,
|
||||
})
|
||||
);
|
||||
assert!(!format!("{call:?}").contains("not-for-debug"));
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ExecutedToolCall>(wire)?,
|
||||
without_metadata
|
||||
);
|
||||
}
|
||||
|
||||
let exact_limit = serde_json::json!({
|
||||
"value": "x".repeat(MAX_EXECUTED_TOOL_CALL_METADATA_BYTES - r#"{"value":""}"#.len()),
|
||||
});
|
||||
let capture = ToolResultMetadata::new(&exact_limit);
|
||||
assert!(capture.is_some());
|
||||
call.set_tool_result_metadata(capture);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&call)?["tool_result_metadata"],
|
||||
exact_limit
|
||||
);
|
||||
// JSON escaping counts toward the bound, not just the in-memory string length.
|
||||
let oversized =
|
||||
serde_json::json!({ "value": "\0".repeat(MAX_EXECUTED_TOOL_CALL_METADATA_BYTES / 2) });
|
||||
let capture = ToolResultMetadata::new(&oversized);
|
||||
assert!(capture.is_some());
|
||||
call.set_tool_result_metadata(capture);
|
||||
let wire = serde_json::to_value(&call)?;
|
||||
assert_eq!(
|
||||
wire,
|
||||
serde_json::json!({
|
||||
"name": "test_tool",
|
||||
"arguments": {},
|
||||
"tool_result_metadata": "omitted_due_to_size_limit",
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ExecutedToolCall>(wire)?,
|
||||
without_metadata
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_result_metadata_is_shed_before_sources_calls_or_completion() -> Result<()> {
|
||||
let mut call = ExecutedToolCall::new("test_tool".to_string(), serde_json::json!({}));
|
||||
call.set_tool_result_sources(ToolResultSources::new(vec![ToolResultSource {
|
||||
r#type: "test_resource".to_string(),
|
||||
id: "R1".to_string(),
|
||||
}]));
|
||||
let mut items = ["first", "second", "without-metadata"].map(|call_id| {
|
||||
let mut item = ResponseItem::from(ResponseInputItem::FunctionCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
output: FunctionCallOutputPayload::from_text("unchanged output".to_string()),
|
||||
});
|
||||
item.append_executed_tool_calls(vec![call.clone()]);
|
||||
item.set_tool_call_cell_id(call_id);
|
||||
item.mark_tool_calls_complete();
|
||||
item
|
||||
});
|
||||
let mut expected = items.clone();
|
||||
let metadata = ToolResultMetadata::new(&serde_json::json!({ "large": "x".repeat(20_000) }));
|
||||
for item in &mut items[..2] {
|
||||
first_executed_tool_call(item)
|
||||
.unwrap()
|
||||
.set_tool_result_metadata(metadata.clone());
|
||||
}
|
||||
for item in &mut expected[..2] {
|
||||
first_executed_tool_call(item)
|
||||
.unwrap()
|
||||
.set_tool_result_metadata(ToolResultMetadata::new(&serde_json::json!(
|
||||
"omitted_due_to_size_limit"
|
||||
)));
|
||||
}
|
||||
assert!(
|
||||
items
|
||||
.iter()
|
||||
.map(executed_tool_call_metadata_bytes)
|
||||
.sum::<usize>()
|
||||
> MAX_EXECUTED_TOOL_CALL_METADATA_BYTES
|
||||
);
|
||||
bound_executed_tool_calls_for_prompt(&mut items);
|
||||
assert_eq!(items, expected);
|
||||
assert!(
|
||||
items
|
||||
.iter()
|
||||
.map(executed_tool_call_metadata_bytes)
|
||||
.sum::<usize>()
|
||||
<= MAX_EXECUTED_TOOL_CALL_METADATA_BYTES
|
||||
);
|
||||
bound_executed_tool_calls_for_prompt(&mut items);
|
||||
assert_eq!(items, expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_metadata_markers_do_not_displace_existing_evidence() {
|
||||
let mut item = ResponseItem::from(ResponseInputItem::FunctionCallOutput {
|
||||
call_id: "exec".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text("unchanged output".to_string()),
|
||||
});
|
||||
let mut call = ExecutedToolCall::new("test_tool".to_string(), serde_json::json!({}));
|
||||
call.set_tool_result_sources(ToolResultSources::new(vec![ToolResultSource {
|
||||
r#type: "test_resource".to_string(),
|
||||
id: "R1".to_string(),
|
||||
}]));
|
||||
item.append_executed_tool_calls(vec![call; 4]);
|
||||
item.set_tool_call_cell_id("exec");
|
||||
item.mark_tool_calls_complete();
|
||||
let remaining_bytes =
|
||||
MAX_EXECUTED_TOOL_CALL_METADATA_BYTES - executed_tool_call_metadata_bytes(&item);
|
||||
let calls = item
|
||||
.ensure_tool_call_metadata()
|
||||
.unwrap()
|
||||
.executed_tool_calls
|
||||
.as_mut()
|
||||
.unwrap();
|
||||
for (index, call) in calls.iter_mut().enumerate() {
|
||||
// Fill the budget exactly, with every argument still below its individual limit.
|
||||
call.arguments = ExecutedToolCallArguments::Raw(serde_json::json!(
|
||||
"x".repeat(remaining_bytes / 4 + usize::from(index < remaining_bytes % 4))
|
||||
));
|
||||
}
|
||||
assert_eq!(
|
||||
executed_tool_call_metadata_bytes(&item),
|
||||
MAX_EXECUTED_TOOL_CALL_METADATA_BYTES
|
||||
);
|
||||
let expected = item.clone();
|
||||
first_executed_tool_call(&mut item)
|
||||
.unwrap()
|
||||
.set_tool_result_metadata(ToolResultMetadata::new(
|
||||
&serde_json::json!({ "too-large": "x".repeat(32 * 1024) }),
|
||||
));
|
||||
bound_executed_tool_calls_for_prompt(std::slice::from_mut(&mut item));
|
||||
assert_eq!(item, expected);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user