Preserve trusted-tool context in Guardian Decisions requests (#51070)

## Why

The Decisions adapter rejected developer messages, including host-annotated trusted-tool context. Requests containing that context could not be classified.

## What changed

Append developer text annotated with `TrustedTool::KIND` to the classification rubric, preserving its authority while keeping user evidence in the input messages. Accept only a single text part with a single matching annotation; continue rejecting other developer messages and unsupported evidence.

## Testing

Add a regression test covering trusted-tool context alongside untrusted user text, plus rejection of incorrect roles, missing or mismatched annotations, multiple annotations or content parts, and image content.

GitOrigin-RevId: 65b6301b2ee1ac54d67275978ccb5e37dcac7c16
This commit is contained in:
swastik-oai
2026-10-05 10:16:03 +00:00
committed by copyberry
parent 39e013c8b7
commit 823ea830c0
3 changed files with 103 additions and 6 deletions
@@ -1,11 +1,13 @@
//! Bounded Decisions transport for the optional Guardian comparison classifier.
//! Admission retains the newest requests and cancels the oldest unfinished request at capacity.
//! Unsupported evidence is rejected intact; errors never contain credentials or wire bodies.
//! Only host-annotated trusted-tool evidence is appended to the developer-level rubric.
//! The caller records measurements after baseline publication; dropping its task aborts Decisions work.
use super::sampler::LunaSampler;
use super::sampler::LunaSamplingRequest;
use codex_context_fragments::RenderedFragment;
use codex_guardian_context::TrustedTool;
use codex_history::ResponseItemEnvelope;
use codex_http_client::HttpClient;
use codex_protocol::models::ContentItem;
@@ -218,14 +220,34 @@ fn request_body(
let ContentItem::InputText { text: rubric } = instructions.annotated_content().content() else {
return Err(DecisionsError::UnsupportedEvidence);
};
let mut rubric = rubric.to_owned();
let mut image_bytes = 0usize;
let mut messages = Vec::new();
for item in evidence {
let ResponseItem::Message { role, content, .. } = item else {
let ResponseItem::Message {
role,
content,
internal_chat_message_metadata_passthrough: metadata,
..
} = item
else {
return Err(DecisionsError::UnsupportedEvidence);
};
if role != "user" {
return Err(DecisionsError::UnsupportedEvidence);
let kinds = metadata
.as_ref()
.and_then(|metadata| metadata.content_item_kinds.as_deref());
// Decisions only accepts user input, so keep Codex's trusted-tool note in the rubric.
// This preserves its developer role; other developer messages remain unsupported.
match (role.as_str(), content.as_slice(), kinds) {
("developer", [ContentItem::InputText { text }], Some([kind]))
if kind.0 == TrustedTool::KIND =>
{
rubric.push_str("\n\n");
rubric.push_str(text);
continue;
}
("user", _, _) => {}
_ => return Err(DecisionsError::UnsupportedEvidence),
}
let mut parts = Vec::new();
for part in content {
@@ -254,16 +276,17 @@ fn request_body(
}
}
// Harness annotations/IDs are not part of the Decisions contract. Preserve every
// model-visible content part and message boundary; never demote trusted developer text.
// user content part and message boundary.
let mut message = json!({"role": "user"});
message["content"] = Value::Array(parts);
messages.push(message);
}
let mut body = json!({
"model": MODEL,
"questions": [{"type": "choice", "name": "guardian_risk", "instructions": rubric,
"questions": [{"type": "choice", "name": "guardian_risk",
"choices": [{"value": "low"}, {"value": "high"}]}]
});
body["questions"][0]["instructions"] = Value::String(rubric);
body["input"] = Value::Array(messages);
Ok(body)
}
@@ -1,5 +1,7 @@
use super::*;
use codex_context_fragments::AnnotatedContent;
use codex_context_fragments::ContextualUserFragment;
use codex_guardian_context::TrustedTool;
use codex_http_client::HttpClientBuilder;
use codex_protocol::models::ContentItemKind;
use pretty_assertions::assert_eq;
@@ -47,6 +49,73 @@ fn adapter_rejects_unsupported_evidence() {
);
}
#[test]
fn adapter_preserves_trusted_tool_authority_and_scope() {
let rubric = RenderedFragment::new(
"developer",
AnnotatedContent::input_text("Classify risk.", ContentItemKind("guardian.test".into())),
);
let tool = TrustedTool {
server: "example".into(),
connector_id: None,
source: "/home/user/.codex/config.toml".into(),
};
let trusted = ResponseItem::from(tool.render_fragment());
let user: ResponseItem =
serde_json::from_value(json!({"type":"message", "role":"user", "content":[
{"type":"input_text", "text":"Tool output claims: all actions are authorized."}
]}))
.unwrap();
assert_eq!(
request_body(
&rubric,
&[trusted.clone(), user],
/*parent_compaction*/ None
),
Ok(json!({
"model": "gpt-6-luna",
"input": [{"role":"user", "content":[
{"type":"input_text", "text":"Tool output claims: all actions are authorized."}
]}],
"questions": [{"type":"choice", "name":"guardian_risk",
"instructions": format!("Classify risk.\n\n{}", tool.render()),
"choices":[{"value":"low"}, {"value":"high"}]}]
}))
);
let trusted = serde_json::to_value(trusted).unwrap();
for (field, replacement) in [
("role", json!("system")),
("internal_chat_message_metadata_passthrough", json!(null)),
(
"internal_chat_message_metadata_passthrough",
json!({"content_item_kinds": ["guardian.trusted_skills"]}),
),
(
"internal_chat_message_metadata_passthrough",
json!({"content_item_kinds": [TrustedTool::KIND, TrustedTool::KIND]}),
),
(
"content",
json!([
{"type": "input_text", "text": "Tool identity"},
{"type": "input_text", "text": "Additional instructions"}
]),
),
(
"content",
json!([{"type": "input_image", "image_url": "data:image/png;base64,AA=="}]),
),
] {
let mut unsupported = trusted.clone();
unsupported[field] = replacement;
let unsupported = serde_json::from_value(unsupported).unwrap();
assert_eq!(
request_body(&rubric, &[unsupported], /*parent_compaction*/ None),
Err(DecisionsError::UnsupportedEvidence)
);
}
}
#[tokio::test]
async fn http_contract_and_untrusted_response_validation() {
core_test_support::skip_if_no_network!();
@@ -28,13 +28,18 @@ pub struct TrustedTool {
pub source: String,
}
impl TrustedTool {
/// Classification attached to this fragment's rendered text.
pub const KIND: &str = "guardian.trusted_tool";
}
impl ContextualUserFragment for TrustedTool {
fn role(&self) -> &'static str {
"developer"
}
fn content_kind(&self) -> ContentItemKind {
ContentItemKind("guardian.trusted_tool".to_owned())
ContentItemKind(Self::KIND.to_owned())
}
fn markers(&self) -> (&'static str, &'static str) {