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:
ningyi-oai
2026-09-09 23:46:17 +00:00
committed by copyberry
parent b5544d5732
commit 0df6366a87
18 changed files with 1198 additions and 120 deletions
@@ -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 {