Record result sources in app tool analytics (#42164)

## What changed

- Add per-tool `analytics_result_source` requirements, with support for the
  `detailed_message_search_v1` format and normal configuration precedence.
- Attach host-generated source IDs from accepted app tool results to recorded
  executed tool calls for both direct and Code Mode invocations.
- Bound and deduplicate source metadata, reject caller-supplied values, and
  shed optional source data before dropping tool-call or completion metadata.
- Preserve source updates across waits and retries while preventing stale or
  compacted records from overwriting accepted metadata.

## Testing

Add coverage for requirement parsing and merging, trusted source snapshots,
metadata budgets, retries, waits, and compaction.

GitOrigin-RevId: 7d5e394219eb33e0ab7b6db3f039138a78569d17
This commit is contained in:
ningyi-oai
2026-09-02 01:06:18 +00:00
committed by copyberry
parent 8971fc25a9
commit 12ed76b09b
11 changed files with 584 additions and 17 deletions
@@ -7,6 +7,10 @@ use super::InternalChatMessageMetadataPassthrough;
use super::ResponseItem;
const MAX_EXECUTED_TOOL_CALL_ARGUMENT_BYTES: usize = 8 * 1024;
/// Maximum distinct result sources retained for one tool invocation.
const MAX_TOOL_RESULT_SOURCES: usize = 32;
/// Maximum UTF-8 bytes for each source's `type` and `id` separately, not the source list.
pub const MAX_TOOL_RESULT_SOURCE_FIELD_BYTES: usize = 128;
/// Maximum serialized warehouse-only attempted-tool metadata in one request.
const MAX_EXECUTED_TOOL_CALL_METADATA_BYTES: usize = 32 * 1024;
const EXECUTED_TOOL_CALL_METADATA_FIELD_BYTES: usize = b"\"executed_tool_calls\":".len();
@@ -56,6 +60,18 @@ pub fn executed_tool_call_metadata_bytes(item: &ResponseItem) -> usize {
.saturating_add(executed_tool_call_metadata_field_bytes(metadata))
}
impl InternalChatMessageMetadataPassthrough {
/// Compares call order, names and arguments, ignoring optional source 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()
&& recorded.iter().zip(calls).all(|(recorded, call)| {
recorded.name == call.name && recorded.arguments() == call.arguments()
})
})
}
}
/// Bounds attempted-tool metadata fairly across the complete serialized request.
pub fn bound_executed_tool_calls_for_prompt(items: &mut [ResponseItem]) {
bound_executed_tool_calls_for_prompt_with_priority(items, /*prioritize_recent*/ false);
@@ -112,6 +128,23 @@ fn bound_executed_tool_calls_for_prompt_with_priority(
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;
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() {
call.tool_result_sources = None;
}
}
original_metadata_bytes =
original_metadata_bytes.saturating_add(executed_tool_call_metadata_bytes(item));
}
}
// A terminal marker can be on an empty wait output, separate from the lost calls.
if truncated || original_metadata_bytes > MAX_EXECUTED_TOOL_CALL_METADATA_BYTES {
for item in items.iter_mut() {
@@ -298,6 +331,58 @@ pub struct ExecutedToolCall {
pub name: String,
#[ts(type = "unknown")]
arguments: ExecutedToolCallArguments,
/// Host-generated analytics only: ignore input JSON rather than accepting caller-supplied
/// evidence, and keep this out of public schemas and generated clients.
#[serde(default, skip_deserializing, skip_serializing_if = "Option::is_none")]
#[schemars(skip)]
#[ts(skip)]
tool_result_sources: Option<ToolResultSourcesValue>,
}
/// 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>),
}
impl ToolResultSources {
/// Deduplicates a complete source list, discarding all sources if it exceeds a limit.
pub fn new(sources: Vec<ToolResultSource>) -> Self {
let mut unique_sources = Vec::new();
for source in sources {
if unique_sources.contains(&source) {
continue;
}
if unique_sources.len() == MAX_TOOL_RESULT_SOURCES
|| source.r#type.len() > MAX_TOOL_RESULT_SOURCE_FIELD_BYTES
|| source.id.len() > MAX_TOOL_RESULT_SOURCE_FIELD_BYTES
{
return Self(None);
}
unique_sources.push(source);
}
Self(Some(ToolResultSourcesValue::Sources(unique_sources)))
}
/// Records that source parsing was attempted but failed, not that a budget was exceeded.
pub fn parse_failed() -> Self {
Self(Some(ToolResultSourcesValue::ParseFailed))
}
}
/// A trusted source identity observed in an accepted tool result by the host.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ToolResultSource {
#[serde(rename = "type")]
pub r#type: String,
pub id: String,
}
/// Trusted truncation details generated locally for an oversized attempted tool call.
@@ -325,6 +410,7 @@ impl ExecutedToolCall {
Self {
name,
arguments: ExecutedToolCallArguments::Raw(arguments),
tool_result_sources: None,
}
}
@@ -340,6 +426,12 @@ impl ExecutedToolCall {
&self.arguments
}
/// Replaces this invocation's capture outcome, including clearing omitted evidence.
pub fn set_tool_result_sources(&mut self, sources: ToolResultSources) -> bool {
self.tool_result_sources = sources.0;
self.tool_result_sources.is_some()
}
fn truncation(&self) -> Option<&ExecutedToolCallTruncation> {
match &self.arguments {
ExecutedToolCallArguments::Raw(_) => None,
@@ -140,6 +140,42 @@ fn executed_tool_call_prompt_budget_includes_metadata_fields() -> Result<()> {
assert_eq!(oversized_items, bounded_items);
}
let mut items = ["exec", "wait"].map(|call_id| {
let mut item = ResponseItem::from(ResponseInputItem::FunctionCallOutput {
call_id: call_id.to_string(),
output: FunctionCallOutputPayload::from_text(String::new()),
});
item.set_tool_call_cell_id("cell-\"\\");
item
});
items[0].append_executed_tool_calls(vec![
ExecutedToolCall::new(
"test_tool".to_string(),
serde_json::json!({ "payload": "x".repeat(MAX_EXECUTED_TOOL_CALL_ARGUMENT_BYTES - 256) }),
);
4
]);
items[1].mark_tool_calls_complete();
let expected = items.clone();
assert!(
first_executed_tool_call(&mut items[0])
.expect("recorded call should exist")
.set_tool_result_sources(ToolResultSources::new(
(0..MAX_TOOL_RESULT_SOURCES)
.map(|index| ToolResultSource {
r#type: "document".to_string(),
id: format!(
"R{index:0width$}",
width = MAX_TOOL_RESULT_SOURCE_FIELD_BYTES - 1
),
})
.collect(),
))
);
assert!(metadata_bytes(&items)? > MAX_EXECUTED_TOOL_CALL_METADATA_BYTES);
bound_executed_tool_calls_for_prompt(&mut items);
assert_eq!(items, expected);
// Empty waits can carry only the marker; its bytes still count toward the budget.
for metadata in [None, Some(passthrough_metadata("turn-1"))] {
let mut item = ResponseItem::from(ResponseInputItem::FunctionCallOutput {
@@ -229,6 +265,17 @@ fn tool_call_completeness_is_host_only_and_fail_closed() -> Result<()> {
"tool_calls_complete": true,
}))?;
assert_eq!(untrusted, passthrough_metadata("turn-1"));
for sources in [
serde_json::json!([{ "type": "test_resource", "id": "ATTACKER" }]),
serde_json::json!("parse_failed"),
] {
let untrusted_call = serde_json::from_value::<ExecutedToolCall>(serde_json::json!({
"name": "test_tool",
"arguments": {},
"tool_result_sources": sources,
}))?;
assert_eq!(untrusted_call, call);
}
let mut item = ResponseItem::from(ResponseInputItem::FunctionCallOutput {
call_id: "call-1".to_string(),
@@ -272,3 +319,72 @@ fn tool_call_completeness_is_host_only_and_fail_closed() -> Result<()> {
}
Ok(())
}
#[test]
fn tool_result_source_snapshots_replace_atomically() -> Result<()> {
let source = |kind: &str, id: &str| ToolResultSource {
r#type: kind.to_string(),
id: id.to_string(),
};
let mut call = ExecutedToolCall::new("test_tool".to_string(), serde_json::json!({}));
let mut sources = (0..MAX_TOOL_RESULT_SOURCES - 1)
.map(|index| source("test_resource", &format!("R{index}")))
.collect::<Vec<_>>();
sources.push(source("other_resource", "R0"));
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!(call.set_tool_result_sources(capture));
sources.push(source("test_resource", "OVERFLOW"));
let capture = ToolResultSources::new(sources);
assert_eq!(capture, ToolResultSources(None));
assert!(!call.set_tool_result_sources(capture));
assert!(
serde_json::to_value(&call)?
.get("tool_result_sources")
.is_none()
);
// Measure UTF-8 bytes, and clear old evidence instead of keeping a partial replacement.
let field = format!(
"é{}",
"x".repeat(MAX_TOOL_RESULT_SOURCE_FIELD_BYTES - "é".len())
);
let bounded = source(&field, &field);
let oversized = format!("{field}x");
for invalid in [
source(&oversized, "R1"),
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()]))
);
let capture = ToolResultSources::new(vec![source("test_resource", "R1"), invalid]);
assert_eq!(capture, ToolResultSources(None));
assert!(!call.set_tool_result_sources(capture));
assert_eq!(call.tool_result_sources, None);
}
assert!(call.set_tool_result_sources(ToolResultSources::new(vec![bounded])));
assert!(call.set_tool_result_sources(ToolResultSources::parse_failed()));
assert_eq!(
serde_json::to_value(&call)?,
serde_json::json!({
"name": "test_tool",
"arguments": {},
"tool_result_sources": "parse_failed",
})
);
assert!(call.set_tool_result_sources(ToolResultSources::new(Vec::new())));
assert_eq!(
serde_json::to_value(&call)?["tool_result_sources"],
serde_json::json!([])
);
Ok(())
}