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:
felixxia-oai authored and copyberry committed 2026-09-04 20:27:18 +00:00
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)
}
+12
View File
@@ -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;
}