Replace Guardian tickets with parent response IDs (#43002)

## What changed

Capture the ID from `response.created` and pass it as `parent_response_id` in Guardian review and classifier request metadata. Clear the current response ID before sampling retries so tool reviews cannot inherit a failed response's ID.

Request Guardian credits with `guardian_credits_requested` on eligible parent requests. Remove the ticket protocol, transport attachment, and ticket-specific body redaction. Keep ordinary app-server client `parent_response_id` values inside `x-codex-turn-metadata` rather than promoting them to Guardian request metadata.

## Testing

Update coverage for HTTP fallback, WebSocket review reuse, missing response IDs, classifier transport retries, and code-mode yield/resume attribution. Extend app-server tests to verify client metadata placement over HTTP and WebSocket.

GitOrigin-RevId: c1665e56382ecec8d260fc2ca0ecd5e20b0b2391
This commit is contained in:
jif authored and copyberry committed 2026-09-05 13:02:49 +00:00
1 parent a7a4321593
commit dee21ec1bc
32 files changed
+178 -362

No files matched your search

@@ -512,11 +512,10 @@ impl GuardianV2Extension {
let thread_id = input.thread_store.level_id().to_owned();
let turn_id = input.turn_id.to_owned();
let root_turn_id = input.root_turn_id.map(str::to_owned);
let guardian_ticket = input
let parent_response_id = input
.turn_store
.get::<codex_protocol::guardian_ticket::GuardianTicket>()
.as_deref()
.cloned();
.get::<codex_api::ResponseId>()
.map(|id| id.0.clone());
let thread_context: Result<_, String> = async {
let parsed_thread_id =
ThreadId::from_string(&thread_id).map_err(|error| error.to_string())?;
@@ -839,7 +838,7 @@ impl GuardianV2Extension {
let instructions = guardian_config.render_classifier_instructions(policy);
let output = match sampler
.sample(LunaSamplingRequest {
guardian_ticket,
parent_response_id,
instructions,
trusted_review_evidence,
trusted_tool_context,
@@ -90,8 +90,8 @@ pub struct LunaSamplerConfig {
/// One tool-less Luna classification request over an already-open connection.
pub struct LunaSamplingRequest {
/// Runtime receipt of the response handling the classified tool.
pub guardian_ticket: Option<codex_protocol::guardian_ticket::GuardianTicket>,
/// ID of the response handling the classified tool.
pub parent_response_id: Option<String>,
/// Trusted instructions describing the requested classification.
pub instructions: String,
/// Host-supplied Guardian reviews isolated from untrusted transcript entries.
@@ -454,7 +454,7 @@ impl LunaSampler {
}
// A classification is its own inference turn; retries keep that identity.
let turn_id = Uuid::now_v7().to_string();
let guardian_ticket = request.guardian_ticket;
let parent_response_id = request.parent_response_id;
let parent_turn_id = request.parent_turn_id;
let root_turn_id = request.root_turn_id;
let mut input = vec![
@@ -629,6 +629,11 @@ impl LunaSampler {
turn_metadata["root_turn_id"] = json!(root_turn_id);
}
client_metadata.insert(TURN_METADATA_KEY.to_owned(), turn_metadata.to_string());
if lease.connection.endpoint == ResponsesEndpoint::GuardianClassifier
&& let Some(parent_response_id) = &parent_response_id
{
client_metadata.insert("parent_response_id".to_owned(), parent_response_id.clone());
}
request.client_metadata = Some(client_metadata);
let mut stream = match lease
.connection
@@ -637,7 +642,6 @@ impl LunaSampler {
ResponsesWsRequest::ResponseCreate((&request).into()),
/*connection_reused*/ true,
/*turn_state*/ None,
guardian_ticket.as_ref(),
)
.await
{
@@ -192,7 +192,7 @@ async fn connect_sampler(config: LunaSamplerConfig) -> Result<LunaSampler> {
fn sample_request(parent_turn_id: &str) -> LunaSamplingRequest {
LunaSamplingRequest {
guardian_ticket: None,
parent_response_id: None,
instructions: "Return high for high risk or low for low risk.".to_owned(),
trusted_review_evidence: Vec::new(),
trusted_tool_context: None,
@@ -447,7 +447,7 @@ async fn preconnected_sampler_reuses_authenticated_websocket_for_classifications
let first = sampler
.sample(LunaSamplingRequest {
guardian_ticket: None,
parent_response_id: None,
instructions: "Return high for high risk or low for low risk.".to_owned(),
trusted_review_evidence: Vec::new(),
trusted_tool_context: None,
@@ -479,7 +479,7 @@ async fn preconnected_sampler_reuses_authenticated_websocket_for_classifications
manager.refresh_token_from_authority().await?;
let second = sampler
.sample(LunaSamplingRequest {
guardian_ticket: None,
parent_response_id: None,
instructions: "Return high for high risk or low for low risk.".to_owned(),
trusted_review_evidence: Vec::new(),
trusted_tool_context: None,
@@ -655,7 +655,7 @@ async fn sampler_returns_classification_token_before_terminal_response_events()
let output = tokio::time::timeout(
Duration::from_secs(2),
sampler.sample(LunaSamplingRequest {
guardian_ticket: None,
parent_response_id: None,
instructions: "Return high for high risk or low for low risk.".to_owned(),
trusted_review_evidence: Vec::new(),
trusted_tool_context: None,
@@ -1119,7 +1119,7 @@ async fn sampler_limits_transient_recovery_attempts() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_ticket_survives_classifier_transport_retry() -> Result<()> {
async fn parent_response_id_survives_classifier_transport_retry() -> Result<()> {
skip_if_no_network!(Ok(()));
for free_guardian in [false, true] {
let healthy = responses::start_websocket_server(vec![vec![vec![
@@ -1146,10 +1146,9 @@ async fn guardian_ticket_survives_classifier_transport_retry() -> Result<()> {
);
}
let sampler = connect_sampler(config).await?;
let raw_ticket = "t".repeat(43);
let parent_response_id = "resp-parent";
let mut request = sample_request("turn-1");
request.guardian_ticket =
codex_protocol::guardian_ticket::GuardianTicket::from_server(&raw_ticket);
request.parent_response_id = Some(parent_response_id.to_owned());
assert_eq!(sampler.sample(request).await?, "low");
for server in [&expired, &healthy] {
let requests = server.single_connection();
@@ -1157,12 +1156,12 @@ async fn guardian_ticket_survives_classifier_transport_retry() -> Result<()> {
let body = requests[0].body_json();
assert_eq!(
(
body["client_metadata"].get("guardian_ticket").cloned(),
body["client_metadata"].get("guardian_ticket_requested"),
body["client_metadata"].get("parent_response_id").cloned(),
body["client_metadata"].get("guardian_credits_requested"),
),
(free_guardian.then(|| json!(raw_ticket)), None)
(free_guardian.then(|| json!(parent_response_id)), None)
);
assert!(!body["input"].to_string().contains(&raw_ticket));
assert!(!body["input"].to_string().contains(parent_response_id));
}
}