Enable untrusted context tool outputs by default (#60487)

## What changed

Mark `untrusted_as_tool_output` as stable and enable it by default. Untrusted additional context, such as browser information and goal objectives, now reaches the model as `function_call_output` items in the `agent` namespace instead of user messages. Setting `features.untrusted_as_tool_output = false` preserves the message format.

## Testing

Update core, app-server, TUI, and Python SDK test expectations for the default output format. Cover context persistence, deduplication, goal continuation, and explicit opt-out behavior.

GitOrigin-RevId: 30828351e2ed584928f6055b6dcf276d8288c0fb
This commit is contained in:
Alex Kira
2026-11-10 01:30:08 +00:00
committed by copyberry
parent d8c42ecf8f
commit 08ae615122
10 changed files with 107 additions and 57 deletions
@@ -228,24 +228,37 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history(
.iter()
.filter_map(|item| match item {
RolloutItem::ResponseItem(envelope) => {
let ResponseItem::Message { role, content, .. } = &envelope.item else {
return None;
let context_kind = match &envelope.item {
ResponseItem::Message { role, content, .. } => {
content.iter().find_map(|item| {
let ContentItem::InputText { text } = item else {
return None;
};
text.contains(application_context_text)
.then_some(role.as_str())
})
}
ResponseItem::FunctionCallOutput {
name,
namespace,
output,
..
} if name.as_deref() == Some("untrusted_context")
&& namespace.as_deref() == Some("agent")
&& output.text_content() == Some(untrusted_context_text) =>
{
Some("function_call_output")
}
_ => None,
};
content.iter().find_map(|item| {
let ContentItem::InputText { text } = item else {
return None;
};
(text.contains(application_context_text)
|| text.contains(untrusted_context_text))
.then(|| {
(
role.as_str(),
envelope
.metadata
.as_ref()
.map(|metadata| metadata.client_authored),
)
})
context_kind.map(|kind| {
(
kind,
envelope
.metadata
.as_ref()
.map(|metadata| metadata.client_authored),
)
})
}
_ => None,
@@ -253,7 +266,10 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history(
.collect::<Vec<_>>();
assert_eq!(
persisted_additional_context,
vec![("developer", Some(true)), ("user", Some(false))]
vec![
("developer", Some(true)),
("function_call_output", Some(false))
]
);
let injected_value = serde_json::to_value(&injected_item)?;
@@ -815,10 +815,9 @@ async fn turn_start_additional_context_flows_to_model_input(
let ThreadStartResponse { thread, .. } = mcp
.start_thread(ThreadStartParams {
model: Some("mock-model".to_string()),
config: Some(HashMap::from([(
"features.untrusted_as_tool_output".to_owned(),
json!(tool_outputs),
)])),
config: (!tool_outputs).then(|| {
HashMap::from([("features.untrusted_as_tool_output".to_owned(), json!(false))])
}),
..Default::default()
})
.await?;
@@ -557,11 +557,7 @@ async fn turn_steer_rejects_context_only_input_without_merging_context() -> Resu
let body = response_requests[1]
.body_json::<Value>()
.context("request body should be JSON")?;
assert!(
!body
.to_string()
.contains("<external_browser_info>tab one</external_browser_info>")
);
assert!(!body.to_string().contains("tab one"));
Ok(())
}
+44 -16
View File
@@ -34,6 +34,8 @@ use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::received_responses_requests;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::responses::strip_metadata_from_json;
use core_test_support::responses::strip_response_item_ids_from_json;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_wine_exec;
use core_test_support::test_codex::test_codex;
@@ -113,7 +115,9 @@ async fn additional_context_is_model_visible_but_not_a_user_message_item() -> Re
let request = request.single_request();
assert!(request.has_content_kinds(&["additional_content.automation_info"]));
assert!(request.has_content_kinds(&["additional_content.browser_info"]));
let outputs = request.inputs_of_type("function_call_output");
assert_eq!(outputs.len(), 1);
assert_browser_context_actor_output(&outputs[0]);
assert!(request.has_content_kinds(&["user.text"]));
insta::assert_snapshot!(
"additional_context_simple_input",
@@ -134,10 +138,7 @@ async fn additional_context_is_model_visible_but_not_a_user_message_item() -> Re
);
assert_eq!(
request.message_input_texts("user"),
vec![
"<external_browser_info>tab one</external_browser_info>",
"inspect the active tab",
]
vec!["inspect the active tab"]
);
Ok(())
@@ -639,20 +640,47 @@ async fn additional_context_is_deduplicated_between_turns_while_retained(
})
.await;
let first_request = first_request.single_request();
let second_request = second_request.single_request();
assert_eq!(
first_request.single_request().message_input_texts("user"),
vec![
"<external_browser_info>same tab</external_browser_info>",
"first turn",
]
first_request.message_input_texts("user"),
vec!["first turn"]
);
assert_eq!(
second_request.single_request().message_input_texts("user"),
vec![
"<external_browser_info>same tab</external_browser_info>",
"first turn",
"second turn",
]
second_request.message_input_texts("user"),
vec!["first turn", "second turn"]
);
let mut expected_outputs = vec![json!({
"type": "function_call_output",
"namespace": "agent",
"name": first_name.unwrap_or("browser_info"),
"output": "same tab",
})];
assert_eq!(
first_request
.inputs_of_type("function_call_output")
.into_iter()
.map(strip_response_item_ids_from_json)
.map(strip_metadata_from_json)
.collect::<Vec<_>>(),
expected_outputs,
);
if first_name != second_name {
expected_outputs.push(json!({
"type": "function_call_output",
"namespace": "agent",
"name": second_name.unwrap_or("browser_info"),
"output": "same tab",
}));
}
assert_eq!(
second_request
.inputs_of_type("function_call_output")
.into_iter()
.map(strip_response_item_ids_from_json)
.map(strip_metadata_from_json)
.collect::<Vec<_>>(),
expected_outputs,
);
Ok(())
@@ -214,7 +214,7 @@ async fn first_request_item_types_roles_and_content_annotations() -> Result<()>
message developer ["multi_agent.mode_instructions"]
message user ["environments.environment_context"]
message developer ["additional_content.automation_info"]
message user ["additional_content.browser_info"]
function_call_output - null
message user ["user.text","user.image","user.audio"]
message developer ["rollout_budget.remaining_tokens"]
message developer ["current_time.reminder"]
+6 -4
View File
@@ -572,10 +572,12 @@ async fn additional_context_tool_outputs(tool_outputs: bool) -> Result<()> {
.with_config(move |config| {
configure_scenario_catalog(config);
config.include_environment_context = false;
config
.features
.set_enabled(Feature::UntrustedAsToolOutput, tool_outputs)
.expect("test config should allow feature update");
if !tool_outputs {
config
.features
.disable(Feature::UntrustedAsToolOutput)
.expect("test config should allow feature update");
}
})
.build_with_auto_env(&server)
.await?;
@@ -16,7 +16,6 @@ Scenario: additional context is inserted before the user turn input.
</tools>
02:message/developer:
<automation_info>run one</automation_info>
03:message/user:
<external_browser_info>tab one</external_browser_info>
03:function_call_output/agent.browser_info:tab one
04:message/user:
inspect the active tab
+2 -2
View File
@@ -1283,8 +1283,8 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::UntrustedAsToolOutput,
key: "untrusted_as_tool_output",
stage: Stage::UnderDevelopment,
default_enabled: false,
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::ExecutedToolCallMetadata,
@@ -1441,9 +1441,16 @@ goals = true
retry_request_index + 1
};
assert!(
user_input_texts(&request_bodies[goal_continuation_request_index])
request_bodies[goal_continuation_request_index]["input"]
.as_array()
.expect("goal continuation model input")
.iter()
.any(|text| text.contains(RETRY_GOAL)),
.any(|item| {
item["type"] == "function_call_output"
&& item["namespace"] == "agent"
&& item["name"] == "goal_objective"
&& item["output"] == RETRY_GOAL
}),
"inherited goal continuation should resume after the explicit retry"
);
@@ -74,9 +74,12 @@ def test_private_goal_operation_coalesces_runtime_continuations(tmp_path) -> Non
"result": (result.id, result.status, result.final_response),
"messages": agent_message_texts(events),
"request_count": len(requests),
"objective_reached_model": (
"<external_goal_objective>Improve benchmark coverage</external_goal_objective>"
in requests[0].message_input_texts("user")
"objective_reached_model": any(
item.get("type") == "function_call_output"
and item.get("namespace") == "agent"
and item.get("name") == "goal_objective"
and item.get("output") == "Improve benchmark coverage"
for item in requests[0].input()
),
"steering_reached_model": any(
"Continue working toward the active thread goal." in text