diff --git a/codex-rs/ext/guardian-v2/src/extension.rs b/codex-rs/ext/guardian-v2/src/extension.rs index 16b203c55d..944a54336a 100644 --- a/codex-rs/ext/guardian-v2/src/extension.rs +++ b/codex-rs/ext/guardian-v2/src/extension.rs @@ -127,14 +127,16 @@ impl ToolLifecycleContributor for GuardianV2Extension { return; } }; - let classification_input = format!( - ">>> TRANSCRIPT START\n{transcript}>>> TRANSCRIPT END\n\n\ - The Codex agent has requested the following action:\n\ - >>> APPROVAL REQUEST START\n\ - Planned action JSON:\n\ - {planned_action}\n\ - >>> APPROVAL REQUEST END\n" - ); + let mut classification_input = vec![">>> TRANSCRIPT START\n".to_owned()]; + classification_input.extend(transcript); + classification_input.extend([ + ">>> TRANSCRIPT END\n\n".to_owned(), + "The Codex agent has requested the following action:\n".to_owned(), + ">>> APPROVAL REQUEST START\n".to_owned(), + "Planned action JSON:\n".to_owned(), + format!("{planned_action}\n"), + ">>> APPROVAL REQUEST END\n".to_owned(), + ]); let result: Result<(), String> = async { let output = sampler .sample(LunaSamplingRequest { diff --git a/codex-rs/ext/guardian-v2/src/extension_tests.rs b/codex-rs/ext/guardian-v2/src/extension_tests.rs index 504cb3a870..72d029a2b4 100644 --- a/codex-rs/ext/guardian-v2/src/extension_tests.rs +++ b/codex-rs/ext/guardian-v2/src/extension_tests.rs @@ -162,24 +162,27 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result< json!({"type": "number", "minimum": 0.0, "maximum": 1.0}) ); assert_eq!( - request["input"][2]["content"][0]["text"], - concat!( - ">>> TRANSCRIPT START\n", - "[1] user: Inspect the repository guidelines.\n", - "[2] reasoning: Find the repository documentation.\n", - "[3] tool list_dir call: {\"path\":\".\"}\n", - "[4] tool list_dir result: README.md\n", - "[5] tool read_file call: {\"path\":\"README.md\"}\n", - ">>> TRANSCRIPT END\n\n", - "The Codex agent has requested the following action:\n", - ">>> APPROVAL REQUEST START\n", - "Planned action JSON:\n", - "{\n", - " \"path\": \"README.md\",\n", - " \"tool\": \"read_file\"\n", - "}\n", - ">>> APPROVAL REQUEST END\n", - ) + request["input"][2]["content"], + json!([ + {"type": "input_text", "text": ">>> TRANSCRIPT START\n"}, + {"type": "input_text", "text": "[1] user: Inspect the repository guidelines.\n"}, + {"type": "input_text", "text": "[2] reasoning: Find the repository documentation.\n"}, + {"type": "input_text", "text": "[3] tool list_dir call: {\"path\":\".\"}\n"}, + {"type": "input_text", "text": "[4] tool list_dir result: README.md\n"}, + {"type": "input_text", "text": "[5] tool read_file call: {\"path\":\"README.md\"}\n"}, + {"type": "input_text", "text": ">>> TRANSCRIPT END\n\n"}, + { + "type": "input_text", + "text": "The Codex agent has requested the following action:\n" + }, + {"type": "input_text", "text": ">>> APPROVAL REQUEST START\n"}, + {"type": "input_text", "text": "Planned action JSON:\n"}, + { + "type": "input_text", + "text": "{\n \"path\": \"README.md\",\n \"tool\": \"read_file\"\n}\n" + }, + {"type": "input_text", "text": ">>> APPROVAL REQUEST END\n"}, + ]) ); let score = tokio::time::timeout(Duration::from_secs(5), async { loop { diff --git a/codex-rs/ext/guardian-v2/src/sampler.rs b/codex-rs/ext/guardian-v2/src/sampler.rs index 827bd6b6ed..3978019eb5 100644 --- a/codex-rs/ext/guardian-v2/src/sampler.rs +++ b/codex-rs/ext/guardian-v2/src/sampler.rs @@ -66,8 +66,8 @@ pub struct LunaSamplerConfig { pub struct LunaSamplingRequest { /// Trusted instructions describing the requested classification. pub instructions: String, - /// Untrusted input that the model should classify. - pub input: String, + /// Ordered untrusted input entries that the model should classify. + pub input: Vec, /// Strict JSON schema constraining the model response. pub output_schema: Value, /// Reasoning budget explicitly selected for this request. @@ -284,9 +284,11 @@ impl LunaSampler { ResponseItem::Message { id: None, role: "user".to_owned(), - content: vec![ContentItem::InputText { - text: request.input, - }], + content: request + .input + .into_iter() + .map(|text| ContentItem::InputText { text }) + .collect(), phase: None, internal_chat_message_metadata_passthrough: None, }, diff --git a/codex-rs/ext/guardian-v2/src/sampler_tests.rs b/codex-rs/ext/guardian-v2/src/sampler_tests.rs index 0dfaec47dc..2d1c30a427 100644 --- a/codex-rs/ext/guardian-v2/src/sampler_tests.rs +++ b/codex-rs/ext/guardian-v2/src/sampler_tests.rs @@ -69,7 +69,7 @@ fn sampler_config(base_url: String) -> LunaSamplerConfig { fn sample_request(turn_id: &str) -> LunaSamplingRequest { LunaSamplingRequest { instructions: "Return a risk score.".to_owned(), - input: "The user requested a README summary.".to_owned(), + input: vec!["The user requested a README summary.".to_owned()], output_schema: json!({ "type": "object", "properties": { "score": { "type": "number" } }, @@ -157,7 +157,10 @@ async fn preconnected_sampler_reuses_authenticated_websocket_for_structured_requ let first = sampler .sample(LunaSamplingRequest { instructions: "Return a risk score.".to_owned(), - input: "The user requested a README summary.".to_owned(), + input: vec![ + "The user requested a README summary.".to_owned(), + "The assistant inspected README.md.".to_owned(), + ], output_schema: schema.clone(), reasoning_effort: ReasoningEffort::None, turn_id: "turn-1".to_owned(), @@ -178,7 +181,7 @@ async fn preconnected_sampler_reuses_authenticated_websocket_for_structured_requ let second = sampler .sample(LunaSamplingRequest { instructions: "Return a risk score.".to_owned(), - input: "The user requested a source review.".to_owned(), + input: vec!["The user requested a source review.".to_owned()], output_schema: schema, reasoning_effort: ReasoningEffort::Medium, turn_id: "turn-2".to_owned(), @@ -189,6 +192,13 @@ async fn preconnected_sampler_reuses_authenticated_websocket_for_structured_requ assert_eq!(second, r#"{"score":0.75}"#); let requests = server.single_connection(); assert_eq!(requests.len(), 2); + assert_eq!( + requests[0].body_json()["input"][2]["content"], + json!([ + {"type": "input_text", "text": "The user requested a README summary."}, + {"type": "input_text", "text": "The assistant inspected README.md."}, + ]) + ); for (index, request) in requests.iter().enumerate() { let request = request.body_json(); assert_eq!(request["type"], "response.create"); @@ -254,7 +264,7 @@ async fn sampler_returns_complete_json_before_terminal_response_events() -> Resu Duration::from_secs(2), sampler.sample(LunaSamplingRequest { instructions: "Return a risk score.".to_owned(), - input: "The user requested a README summary.".to_owned(), + input: vec!["The user requested a README summary.".to_owned()], output_schema: json!({ "type": "object", "properties": { "score": { "type": "number" } }, diff --git a/codex-rs/ext/guardian-v2/src/transcript.rs b/codex-rs/ext/guardian-v2/src/transcript.rs index ba6dcb4d83..783b279018 100644 --- a/codex-rs/ext/guardian-v2/src/transcript.rs +++ b/codex-rs/ext/guardian-v2/src/transcript.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::collections::VecDeque; use codex_extension_api::ResponseItem; use codex_protocol::models::ContentItem; @@ -36,8 +37,12 @@ impl Default for TranscriptConfig { } impl TranscriptConfig { - pub(crate) fn build<'a>(&self, items: impl IntoIterator) -> String { - let mut transcript = String::new(); + pub(crate) fn build<'a>( + &self, + items: impl IntoIterator, + ) -> Vec { + let mut transcript = VecDeque::new(); + let mut transcript_bytes = 0; let mut tool_names_by_call_id = HashMap::new(); let mut entry_number = 0; @@ -173,18 +178,30 @@ impl TranscriptConfig { continue; } entry_number += 1; - transcript.push_str(&format!("[{entry_number}] {role}: {text}\n")); + let entry = format!("[{entry_number}] {role}: {text}\n"); + transcript_bytes += entry.len(); + transcript.push_back(entry); - if transcript.len() > MAX_TRANSCRIPT_BYTES { - let mut first_retained_byte = transcript.len() - MAX_TRANSCRIPT_BYTES; - while !transcript.is_char_boundary(first_retained_byte) { - first_retained_byte += 1; + while transcript_bytes > MAX_TRANSCRIPT_BYTES { + let Some(first_entry) = transcript.front_mut() else { + break; + }; + let bytes_to_remove = transcript_bytes - MAX_TRANSCRIPT_BYTES; + if first_entry.len() <= bytes_to_remove { + transcript_bytes -= first_entry.len(); + transcript.pop_front(); + } else { + let mut first_retained_byte = bytes_to_remove; + while !first_entry.is_char_boundary(first_retained_byte) { + first_retained_byte += 1; + } + first_entry.drain(..first_retained_byte); + transcript_bytes -= first_retained_byte; } - transcript.drain(..first_retained_byte); } } - transcript + transcript.into() } } diff --git a/codex-rs/ext/guardian-v2/src/transcript_tests.rs b/codex-rs/ext/guardian-v2/src/transcript_tests.rs index bbcb92a1ea..301bc9ab4e 100644 --- a/codex-rs/ext/guardian-v2/src/transcript_tests.rs +++ b/codex-rs/ext/guardian-v2/src/transcript_tests.rs @@ -62,13 +62,12 @@ fn transcript_keeps_conversation_and_configured_sources() { let transcript = TranscriptConfig::default().build(&items); assert_eq!( transcript, - concat!( + vec![ "[1] user: Inspect the workspace.\n", "[2] tool exec_command call: {}\n", "[3] tool exec_command result: Workspace inspected.\n", - "[4] reasoning: Review the current files.\n", - "Plaintext reasoning.\n", - ) + "[4] reasoning: Review the current files.\nPlaintext reasoning.\n", + ] ); let output_and_reasoning = TranscriptConfig { @@ -78,12 +77,11 @@ fn transcript_keeps_conversation_and_configured_sources() { let transcript = output_and_reasoning.build(&items); assert_eq!( transcript, - concat!( + vec![ "[1] user: Inspect the workspace.\n", "[2] tool exec_command result: Workspace inspected.\n", - "[3] reasoning: Review the current files.\n", - "Plaintext reasoning.\n", - ) + "[3] reasoning: Review the current files.\nPlaintext reasoning.\n", + ] ); let calls_only = TranscriptConfig { @@ -93,10 +91,10 @@ fn transcript_keeps_conversation_and_configured_sources() { let transcript = calls_only.build(&items); assert_eq!( transcript, - concat!( + vec![ "[1] user: Inspect the workspace.\n", "[2] tool exec_command call: {}\n", - ) + ] ); } @@ -125,8 +123,12 @@ fn transcript_retains_the_most_recent_bounded_content() { let transcript = TranscriptConfig::default().build(&items); - assert!(transcript.len() <= MAX_TRANSCRIPT_BYTES); - assert!(transcript.contains("latest response")); + assert!(transcript.iter().map(String::len).sum::() <= MAX_TRANSCRIPT_BYTES); + assert!( + transcript + .iter() + .any(|entry| entry.contains("latest response")) + ); } #[test] @@ -154,7 +156,10 @@ fn transcript_keeps_only_manual_approval_developer_messages() { ]; let transcript = TranscriptConfig::default().build(&items); - assert_eq!(transcript, format!("[1] developer: {approval_text}\n")); + assert_eq!( + transcript, + vec![format!("[1] developer: {approval_text}\n")] + ); } #[test] @@ -224,10 +229,10 @@ fn transcript_omits_media_payloads_and_keeps_readable_content() { let transcript = TranscriptConfig::default().build(&items); assert_eq!( transcript, - concat!( + vec![ "[1] user: Review this screenshot.\n", "[2] tool result: Screenshot captured.\n", - ) + ] ); } @@ -280,11 +285,10 @@ fn transcript_omits_encrypted_messages_arguments_and_tool_outputs() { let transcript = TranscriptConfig::default().build(&items); assert_eq!( transcript, - concat!( - "[1] assistant: Agent message from worker:\n", - "The workspace is ready.\n", + vec![ + "[1] assistant: Agent message from worker:\nThe workspace is ready.\n", "[2] tool exec_command call: {}\n", "[3] tool exec_command result: Command completed.\n", - ) + ] ); }