mirror of
https://github.com/openai/codex.git
synced 2026-11-11 21:10:39 +00:00
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:
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user