mirror of
https://github.com/openai/codex.git
synced 2026-11-09 00:39:32 +00:00
Retain user instructions in Guardian context (#42844)
## Why Guardian reviews need the original user instructions even when compaction or transcript selection removes them from the active conversation window. ## What changed - Capture complete user messages in host-owned retained context and include them as ordered authorization evidence alongside verified answers. - Omit oversized instructions atomically and emit an explicit incomplete-evidence notice instead of exposing a potentially misleading partial permission. - Mark user messages inherited by forked agents so they remain model-visible without becoming child-local authorization. ## Testing - Cover retained instructions across compaction, resume, rollback, and agent forks, including oversized evidence and legacy context behavior. GitOrigin-RevId: 97122509d818c90d2f87c6a1b29c99c05ad0316e
This commit is contained in:
1 parent
147137c1f4
commit
9c4253ffc1
20 files changed
+663
-140
No files matched your search
@@ -30,6 +30,7 @@ impl SectionRegistry {
|
||||
context.transcript.extend(items)
|
||||
}
|
||||
ContextSection::RootConversation { items }
|
||||
| ContextSection::RetainedUserInstructions { items }
|
||||
| ContextSection::TrustedUserAnswers { items } => {
|
||||
context.authorization.extend(items)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use codex_protocol::models::ResponseItem;
|
||||
|
||||
use authorization::RootConversationSection;
|
||||
use authorization::TrustedUserAnswersSection;
|
||||
use retained_instructions::RetainedUserInstructionsSection;
|
||||
use transcript::ConversationTranscriptSection;
|
||||
|
||||
pub use authorization::GuardianRootMessage;
|
||||
@@ -40,6 +41,8 @@ pub use verified_answers::RenderedVerifiedAnswers;
|
||||
pub use verified_answers::render_verified_answer;
|
||||
pub use verified_answers::render_verified_answers;
|
||||
|
||||
mod retained_instructions;
|
||||
|
||||
mod authorization;
|
||||
mod composition;
|
||||
mod entry;
|
||||
@@ -102,6 +105,11 @@ pub struct SectionInput<'a> {
|
||||
pub trait SectionHistory: Send + Sync {
|
||||
/// Returns borrowed response items in their original conversation order.
|
||||
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_>;
|
||||
|
||||
/// Bounded host-owned facts from the same snapshot as the current items.
|
||||
fn retained_context(&self) -> Option<&codex_history::RetainedContext> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl SectionHistory for Vec<ResponseItem> {
|
||||
@@ -170,6 +178,7 @@ pub fn default_registry() -> &'static SectionRegistry {
|
||||
static REGISTRY: LazyLock<SectionRegistry> = LazyLock::new(|| {
|
||||
let mut registry = SectionRegistry::default();
|
||||
registry.register(RootConversationSection);
|
||||
registry.register(RetainedUserInstructionsSection);
|
||||
registry.register(TrustedUserAnswersSection);
|
||||
registry.register(ConversationTranscriptSection);
|
||||
registry
|
||||
@@ -213,6 +222,9 @@ pub enum ContextSection {
|
||||
TrustedUserAnswers {
|
||||
items: Vec<String>,
|
||||
},
|
||||
RetainedUserInstructions {
|
||||
items: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Selects original user instructions independently of model compaction and transcript selection.
|
||||
//! The host owns storage and lifecycle. Omitted records remain explicit; restrictions
|
||||
//! are never truncated into partial permissions, and retained source order is preserved.
|
||||
//! Section omissions do not change fast-approval eligibility.
|
||||
|
||||
use codex_history::RetainedContext;
|
||||
use codex_history::RetainedContextEntry;
|
||||
use codex_protocol::protocol::TruncationPolicy;
|
||||
|
||||
use crate::ContextSection;
|
||||
use crate::GuardianRootMessage;
|
||||
use crate::SectionContributor;
|
||||
use crate::SectionError;
|
||||
use crate::SectionInput;
|
||||
use crate::SectionScope;
|
||||
|
||||
const MAX_INSTRUCTION_TOKENS: usize = 900;
|
||||
|
||||
/// Renders bounded originals even when transcript selection also includes their source messages.
|
||||
/// Presence in parent history alone cannot prove complete delivery to a reviewer.
|
||||
fn render_retained_instructions(context: &RetainedContext) -> Vec<String> {
|
||||
let mut complete = context.user_messages_complete();
|
||||
let mut fragments = Vec::new();
|
||||
for (order, entry) in context.ordered_entries().enumerate() {
|
||||
let RetainedContextEntry::UserMessage(message) = entry else {
|
||||
continue;
|
||||
};
|
||||
let text = format!(
|
||||
"Retained source order: {order}\n{}",
|
||||
GuardianRootMessage::User(message.text.clone()).render()
|
||||
);
|
||||
if message.complete
|
||||
&& text.len() <= TruncationPolicy::Tokens(MAX_INSTRUCTION_TOKENS).byte_budget()
|
||||
{
|
||||
fragments.push(text);
|
||||
} else {
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
if !complete {
|
||||
fragments.insert(/*index*/ 0, "Host notice: some retained user instructions are unavailable within the evidence budget. Do not treat remaining grants as complete authorization.\n".to_owned());
|
||||
}
|
||||
fragments
|
||||
}
|
||||
|
||||
pub(crate) struct RetainedUserInstructionsSection;
|
||||
|
||||
impl SectionContributor for RetainedUserInstructionsSection {
|
||||
fn scope(&self) -> SectionScope {
|
||||
SectionScope::Shared
|
||||
}
|
||||
|
||||
fn contribute(&self, input: &SectionInput<'_>) -> Result<Option<ContextSection>, SectionError> {
|
||||
let Some(context) = input.history.retained_context() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let rendered = render_retained_instructions(context);
|
||||
if rendered.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut items = vec![">>> RETAINED USER INSTRUCTIONS START\nHost: Retained source order labels across instructions and verified answers reflect original acceptance, not section order. Later instructions may revoke earlier grants.\n".to_owned()];
|
||||
items.extend(rendered);
|
||||
items.push(">>> RETAINED USER INSTRUCTIONS END\n".to_owned());
|
||||
Ok(Some(ContextSection::RetainedUserInstructions { items }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "retained_instructions_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,65 @@
|
||||
use super::*;
|
||||
use codex_history::RetainedContextEvent;
|
||||
use codex_history::RetainedUserMessage;
|
||||
use codex_history::VerifiedAnswer;
|
||||
use codex_history::VerifiedQuestionAnswer;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn instructions_preserve_source_order_and_whole_records() {
|
||||
let mut context = RetainedContext::default();
|
||||
// An answer at the existing 900-token byte limit still fits with order framing.
|
||||
let answer = "x".repeat(3_600 - "assistant: Publish?\nuser: \n".len());
|
||||
context.record(&RetainedContextEvent::VerifiedAnswer {
|
||||
answer: VerifiedAnswer {
|
||||
turn_id: "grant".to_owned(),
|
||||
call_id: "ask".to_owned(),
|
||||
questions: vec![VerifiedQuestionAnswer {
|
||||
question: "Publish?".to_owned(),
|
||||
answer: answer.clone(),
|
||||
}],
|
||||
},
|
||||
acceptance_order: None,
|
||||
});
|
||||
context.record_user_message(
|
||||
RetainedUserMessage {
|
||||
turn_id: "revocation".to_owned(),
|
||||
message_id: Some("msg_revoke".to_owned()),
|
||||
text: "Do not publish after all.".to_owned(),
|
||||
complete: true,
|
||||
},
|
||||
/*acceptance_order*/ None,
|
||||
);
|
||||
let rendered = render_retained_instructions(&context);
|
||||
assert_eq!(
|
||||
rendered,
|
||||
vec!["Retained source order: 1\nuser: Do not publish after all.\n"]
|
||||
);
|
||||
let answers = crate::render_verified_answers(&context);
|
||||
assert_eq!(
|
||||
(answers.complete, answers.fragments),
|
||||
(
|
||||
true,
|
||||
vec![format!(
|
||||
"Retained source order: 0\nassistant: Publish?\nuser: {answer}\n"
|
||||
)]
|
||||
),
|
||||
);
|
||||
context.record_user_message(
|
||||
RetainedUserMessage {
|
||||
turn_id: "oversized".to_owned(),
|
||||
message_id: Some("msg_large".to_owned()),
|
||||
text: "Permission is conditional. ".repeat(200),
|
||||
complete: true,
|
||||
},
|
||||
/*acceptance_order*/ None,
|
||||
);
|
||||
let rendered = render_retained_instructions(&context);
|
||||
assert_eq!(rendered.len(), 2);
|
||||
assert!(rendered[0].starts_with("Host notice:"));
|
||||
assert!(
|
||||
!rendered
|
||||
.iter()
|
||||
.any(|fragment| fragment.contains("Permission is conditional"))
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Omit a record atomically when it cannot fit; never truncate a restriction out of a grant.
|
||||
|
||||
use codex_history::RetainedContext;
|
||||
use codex_history::RetainedContextEntry;
|
||||
use codex_history::VerifiedAnswer;
|
||||
|
||||
use crate::GuardianRootMessage;
|
||||
@@ -19,9 +20,13 @@ pub struct RenderedVerifiedAnswers {
|
||||
pub fn render_verified_answers(context: &RetainedContext) -> RenderedVerifiedAnswers {
|
||||
let mut complete = context.verified_answers_complete();
|
||||
let mut fragments = Vec::new();
|
||||
for answer in context.verified_answers() {
|
||||
for (order, entry) in context.ordered_entries().enumerate() {
|
||||
let RetainedContextEntry::VerifiedAnswer(answer) = entry else {
|
||||
continue;
|
||||
};
|
||||
if let Some(text) = render_verified_answer(answer) {
|
||||
fragments.push(text);
|
||||
// The bounded order label is framing, not part of the existing answer budget.
|
||||
fragments.push(format!("Retained source order: {order}\n{text}"));
|
||||
} else {
|
||||
complete = false;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user