mirror of
https://github.com/openai/codex.git
synced 2026-11-06 00:30:29 +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
BIN
Binary file not shown.
@@ -56,9 +56,10 @@ use super::user_input_request_events;
|
|||||||
use super::wait_for_luna_request;
|
use super::wait_for_luna_request;
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum AnswerSize {
|
enum EvidenceSize {
|
||||||
Normal,
|
Normal,
|
||||||
Oversized,
|
OversizedAnswer,
|
||||||
|
OversizedInstruction,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -67,25 +68,26 @@ enum ContextPath {
|
|||||||
ThreadOwned,
|
ThreadOwned,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("matching"), 0, AnswerSize::Normal; "compatible checkpoint")]
|
#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("matching"), 0, EvidenceSize::OversizedInstruction; "instruction budget preserves fresh low score")]
|
||||||
#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("different"), 0, AnswerSize::Normal; "incompatible checkpoint")]
|
#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("matching"), 0, EvidenceSize::Normal; "compatible checkpoint")]
|
||||||
#[test_case(ContextPath::ThreadOwned, Some("matching"), None, 0, AnswerSize::Normal; "unknown Luna compatibility")]
|
#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("different"), 0, EvidenceSize::Normal; "incompatible checkpoint")]
|
||||||
#[test_case(ContextPath::ThreadOwned, Some("matching"), Some(""), 0, AnswerSize::Normal; "empty Luna compatibility")]
|
#[test_case(ContextPath::ThreadOwned, Some("matching"), None, 0, EvidenceSize::Normal; "unknown Luna compatibility")]
|
||||||
#[test_case(ContextPath::ThreadOwned, None, Some("matching"), 0, AnswerSize::Normal; "unknown producer remains unknown after model switch")]
|
#[test_case(ContextPath::ThreadOwned, Some("matching"), Some(""), 0, EvidenceSize::Normal; "empty Luna compatibility")]
|
||||||
#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("matching"), 140, AnswerSize::Normal; "source call evicted")]
|
#[test_case(ContextPath::ThreadOwned, None, Some("matching"), 0, EvidenceSize::Normal; "unknown producer remains unknown after model switch")]
|
||||||
#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("matching"), 0, AnswerSize::Oversized; "incomplete answers reject fresh low score")]
|
#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("matching"), 140, EvidenceSize::Normal; "source call evicted")]
|
||||||
#[test_case(ContextPath::Legacy, Some("matching"), Some("matching"), 0, AnswerSize::Normal; "legacy answers remain runtime only")]
|
#[test_case(ContextPath::ThreadOwned, Some("matching"), Some("matching"), 0, EvidenceSize::OversizedAnswer; "incomplete answers reject fresh low score")]
|
||||||
#[test_case(ContextPath::Legacy, Some("matching"), Some("different"), 0, AnswerSize::Normal; "legacy incompatible checkpoint still samples")]
|
#[test_case(ContextPath::Legacy, Some("matching"), Some("matching"), 0, EvidenceSize::Normal; "legacy answers remain runtime only")]
|
||||||
#[test_case(ContextPath::Legacy, Some("matching"), None, 0, AnswerSize::Normal; "legacy unknown Luna compatibility still samples")]
|
#[test_case(ContextPath::Legacy, Some("matching"), Some("different"), 0, EvidenceSize::Normal; "legacy incompatible checkpoint still samples")]
|
||||||
#[test_case(ContextPath::Legacy, Some("matching"), Some("matching"), 140, AnswerSize::Normal; "legacy source call evicted")]
|
#[test_case(ContextPath::Legacy, Some("matching"), None, 0, EvidenceSize::Normal; "legacy unknown Luna compatibility still samples")]
|
||||||
#[test_case(ContextPath::Legacy, Some("matching"), Some("matching"), 0, AnswerSize::Oversized; "legacy answer truncation")]
|
#[test_case(ContextPath::Legacy, Some("matching"), Some("matching"), 140, EvidenceSize::Normal; "legacy source call evicted")]
|
||||||
|
#[test_case(ContextPath::Legacy, Some("matching"), Some("matching"), 0, EvidenceSize::OversizedAnswer; "legacy answer truncation")]
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollback(
|
async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollback(
|
||||||
context_path: ContextPath,
|
context_path: ContextPath,
|
||||||
parent_hash: Option<&str>,
|
parent_hash: Option<&str>,
|
||||||
luna_hash: Option<&str>,
|
luna_hash: Option<&str>,
|
||||||
tool_traffic: usize,
|
tool_traffic: usize,
|
||||||
answer_size: AnswerSize,
|
evidence_size: EvidenceSize,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
skip_if_no_network!(Ok(()));
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
@@ -94,9 +96,17 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac
|
|||||||
const SUMMARY: &str = "Repository inspection was summarized.";
|
const SUMMARY: &str = "Repository inspection was summarized.";
|
||||||
let compatible = parent_hash == Some("matching") && luna_hash == parent_hash;
|
let compatible = parent_hash == Some("matching") && luna_hash == parent_hash;
|
||||||
let requires_sync = matches!(context_path, ContextPath::ThreadOwned) && !compatible;
|
let requires_sync = matches!(context_path, ContextPath::ThreadOwned) && !compatible;
|
||||||
let answer = match answer_size {
|
let oversized_instruction = matches!(evidence_size, EvidenceSize::OversizedInstruction);
|
||||||
AnswerSize::Normal => USER_INPUT_RESTRICTION.to_owned(),
|
let answer = match evidence_size {
|
||||||
AnswerSize::Oversized => "Never publish this repository publicly. ".repeat(200),
|
EvidenceSize::Normal | EvidenceSize::OversizedInstruction => {
|
||||||
|
USER_INPUT_RESTRICTION.to_owned()
|
||||||
|
}
|
||||||
|
EvidenceSize::OversizedAnswer => "Never publish this repository publicly. ".repeat(200),
|
||||||
|
};
|
||||||
|
let restriction = if oversized_instruction {
|
||||||
|
RESTRICTION.repeat(150)
|
||||||
|
} else {
|
||||||
|
RESTRICTION.to_owned()
|
||||||
};
|
};
|
||||||
let expected_output = format!("\"echoed\":\"{EVIDENCE}\"");
|
let expected_output = format!("\"echoed\":\"{EVIDENCE}\"");
|
||||||
let parent_requests = Arc::new(Mutex::new(Vec::<Value>::new()));
|
let parent_requests = Arc::new(Mutex::new(Vec::<Value>::new()));
|
||||||
@@ -108,11 +118,11 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac
|
|||||||
"encrypted_content": "encrypted summary"
|
"encrypted_content": "encrypted summary"
|
||||||
});
|
});
|
||||||
let rejects_incomplete_score = matches!(
|
let rejects_incomplete_score = matches!(
|
||||||
(context_path, answer_size),
|
(context_path, evidence_size),
|
||||||
(ContextPath::ThreadOwned, AnswerSize::Oversized)
|
(ContextPath::ThreadOwned, EvidenceSize::OversizedAnswer)
|
||||||
);
|
);
|
||||||
let classifier = Arc::new(MockResponsesState {
|
let classifier = Arc::new(MockResponsesState {
|
||||||
luna_score: if rejects_incomplete_score || requires_sync {
|
luna_score: if rejects_incomplete_score || oversized_instruction || requires_sync {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
1.0
|
1.0
|
||||||
@@ -270,7 +280,7 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac
|
|||||||
let thread_id = thread.id.clone();
|
let thread_id = thread.id.clone();
|
||||||
|
|
||||||
for (index, prompt) in [
|
for (index, prompt) in [
|
||||||
RESTRICTION,
|
restriction.as_str(),
|
||||||
"Recheck the repository.",
|
"Recheck the repository.",
|
||||||
"Inspect after resume.",
|
"Inspect after resume.",
|
||||||
"Inspect after partial rollback.",
|
"Inspect after partial rollback.",
|
||||||
@@ -419,10 +429,14 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac
|
|||||||
.split_once(">>> TRANSCRIPT END")
|
.split_once(">>> TRANSCRIPT END")
|
||||||
.expect("transcript end")
|
.expect("transcript end")
|
||||||
.0;
|
.0;
|
||||||
assert!(
|
if index == 0 && oversized_instruction {
|
||||||
transcript.contains(prompt),
|
assert!(content.contains("some retained user instructions are unavailable"));
|
||||||
"current user input missing: {transcript}"
|
} else {
|
||||||
);
|
assert!(
|
||||||
|
transcript.contains(prompt),
|
||||||
|
"current user input missing: {transcript}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let parent = parent_requests.lock().expect("request log lock");
|
let parent = parent_requests.lock().expect("request log lock");
|
||||||
@@ -527,12 +541,12 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac
|
|||||||
panic!("missing {consumer} answers at step {index}: {text}")
|
panic!("missing {consumer} answers at step {index}: {text}")
|
||||||
})
|
})
|
||||||
.1;
|
.1;
|
||||||
match answer_size {
|
match evidence_size {
|
||||||
AnswerSize::Normal => {
|
EvidenceSize::Normal | EvidenceSize::OversizedInstruction => {
|
||||||
assert!(answers.contains("assistant: Can I keep using the browser?"));
|
assert!(answers.contains("assistant: Can I keep using the browser?"));
|
||||||
assert!(answers.contains(&format!("user: {USER_INPUT_RESTRICTION}")));
|
assert!(answers.contains(&format!("user: {USER_INPUT_RESTRICTION}")));
|
||||||
}
|
}
|
||||||
AnswerSize::Oversized => match context_path {
|
EvidenceSize::OversizedAnswer => match context_path {
|
||||||
ContextPath::ThreadOwned => {
|
ContextPath::ThreadOwned => {
|
||||||
assert!(
|
assert!(
|
||||||
answers.contains("some verified user answers are unavailable")
|
answers.contains("some verified user answers are unavailable")
|
||||||
@@ -555,7 +569,7 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
classifier.allow_luna.notify_one();
|
classifier.allow_luna.notify_one();
|
||||||
if rejects_incomplete_score || (index == 0 && requires_sync) {
|
if rejects_incomplete_score || oversized_instruction || (index == 0 && requires_sync) {
|
||||||
// Wait for host publication, not merely a mock response. The next action must
|
// Wait for host publication, not merely a mock response. The next action must
|
||||||
// see a fresh low score with unchanged authorization, rather than a stale score.
|
// see a fresh low score with unchanged authorization, rather than a stale score.
|
||||||
let path = thread.path.as_ref().expect("legacy rollout path");
|
let path = thread.path.as_ref().expect("legacy rollout path");
|
||||||
@@ -578,7 +592,14 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac
|
|||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
if rejects_incomplete_score {
|
if rejects_incomplete_score || oversized_instruction {
|
||||||
|
let notice = match evidence_size {
|
||||||
|
EvidenceSize::OversizedInstruction => {
|
||||||
|
"some retained user instructions are unavailable"
|
||||||
|
}
|
||||||
|
EvidenceSize::OversizedAnswer => "some verified user answers are unavailable",
|
||||||
|
EvidenceSize::Normal => unreachable!("incomplete evidence fixture"),
|
||||||
|
};
|
||||||
let completed = app_server
|
let completed = app_server
|
||||||
.start_turn_and_wait_for_completion(TurnStartParams {
|
.start_turn_and_wait_for_completion(TurnStartParams {
|
||||||
thread_id: thread_id.clone(),
|
thread_id: thread_id.clone(),
|
||||||
@@ -588,21 +609,19 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac
|
|||||||
.await?;
|
.await?;
|
||||||
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
||||||
let fresh_sample = wait_for_luna_request(&classifier, /*index*/ 2).await?;
|
let fresh_sample = wait_for_luna_request(&classifier, /*index*/ 2).await?;
|
||||||
assert!(
|
assert!(fresh_sample.to_string().contains(notice));
|
||||||
fresh_sample
|
|
||||||
.to_string()
|
|
||||||
.contains("some verified user answers are unavailable")
|
|
||||||
);
|
|
||||||
let reviews = review_requests.lock().expect("request log lock");
|
let reviews = review_requests.lock().expect("request log lock");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reviews.len(),
|
reviews.len(),
|
||||||
2,
|
1 + usize::from(rejects_incomplete_score),
|
||||||
"an incomplete fresh score must still require sync review"
|
"only incomplete verified answers require another sync review"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
reviews[1]
|
reviews
|
||||||
|
.last()
|
||||||
|
.expect("initial sync review")
|
||||||
.to_string()
|
.to_string()
|
||||||
.contains("some verified user answers are unavailable")
|
.contains(notice)
|
||||||
);
|
);
|
||||||
classifier.allow_luna.notify_one();
|
classifier.allow_luna.notify_one();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use codex_context_fragments::set_annotated_content;
|
|||||||
use codex_context_fragments::to_annotated_content;
|
use codex_context_fragments::to_annotated_content;
|
||||||
use codex_extension_api::ExtensionDataInit;
|
use codex_extension_api::ExtensionDataInit;
|
||||||
use codex_features::Feature;
|
use codex_features::Feature;
|
||||||
|
use codex_history::ResponseItemEnvelope;
|
||||||
use codex_protocol::intersect_effective_permission_profiles;
|
use codex_protocol::intersect_effective_permission_profiles;
|
||||||
use codex_protocol::protocol::EnvironmentConfigState;
|
use codex_protocol::protocol::EnvironmentConfigState;
|
||||||
use codex_utils_path_uri::PathUri;
|
use codex_utils_path_uri::PathUri;
|
||||||
@@ -932,7 +933,19 @@ impl AgentControl {
|
|||||||
// Scrub inherited hints and replace only the parent's developer-instruction fragment.
|
// Scrub inherited hints and replace only the parent's developer-instruction fragment.
|
||||||
// Compaction stores response items separately, so sanitize both top-level messages and
|
// Compaction stores response items separately, so sanitize both top-level messages and
|
||||||
// compacted replacement histories with the same policy.
|
// compacted replacement histories with the same policy.
|
||||||
let retain_forked_item = |response_item: &mut ResponseItem, replaced: &mut bool| {
|
let retain_forked_item = |envelope: &mut ResponseItemEnvelope, replaced: &mut bool| {
|
||||||
|
if config.features.enabled(Feature::GuardianThreadContext)
|
||||||
|
&& multi_agent_version == MultiAgentVersion::V2
|
||||||
|
&& matches!(&envelope.item, ResponseItem::Message { role, .. } if role == "user")
|
||||||
|
{
|
||||||
|
// Persist the scope of every inherited user message, including the suffix
|
||||||
|
// after a checkpoint. Resume must not recapture it as local authorization.
|
||||||
|
envelope
|
||||||
|
.metadata
|
||||||
|
.get_or_insert_default()
|
||||||
|
.inherited_user_message = true;
|
||||||
|
}
|
||||||
|
let response_item = &mut envelope.item;
|
||||||
if matches!(response_item, ResponseItem::AgentMessage { .. }) {
|
if matches!(response_item, ResponseItem::AgentMessage { .. }) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1016,7 +1029,12 @@ impl AgentControl {
|
|||||||
// Parent-local review evidence must not become the child's authorization.
|
// Parent-local review evidence must not become the child's authorization.
|
||||||
// Root user authorization is collected separately by the host.
|
// Root user authorization is collected separately by the host.
|
||||||
compacted.guardian_history = None;
|
compacted.guardian_history = None;
|
||||||
compacted.retained_context = None;
|
// Only V2 fetches root authorization live. Its local scope starts known-empty;
|
||||||
|
// V1 must remain incomplete when inherited authorization has been stripped.
|
||||||
|
compacted.retained_context =
|
||||||
|
(config.features.enabled(Feature::GuardianThreadContext)
|
||||||
|
&& multi_agent_version == MultiAgentVersion::V2)
|
||||||
|
.then(codex_history::RetainedContext::default);
|
||||||
if let Some(replacement_history) = compacted.replacement_history.as_mut() {
|
if let Some(replacement_history) = compacted.replacement_history.as_mut() {
|
||||||
// Matches before this checkpoint cannot survive its replacement history.
|
// Matches before this checkpoint cannot survive its replacement history.
|
||||||
replaced_parent_developer_instructions = false;
|
replaced_parent_developer_instructions = false;
|
||||||
|
|||||||
@@ -1385,9 +1385,17 @@ async fn spawn_agent_without_fork_from_paginated_parent_stays_fresh_and_paginate
|
|||||||
.expect("parent shutdown should submit");
|
.expect("parent shutdown should submit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test_case::test_case(true; "thread context enabled")]
|
||||||
|
#[test_case::test_case(false; "thread context disabled")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn spawn_agent_fork_drops_inherited_token_usage_state() {
|
async fn spawn_agent_fork_drops_inherited_token_usage_state(thread_context_enabled: bool) {
|
||||||
let harness = AgentControlHarness::new().await;
|
let mut harness = AgentControlHarness::new().await;
|
||||||
|
let _ = harness.config.features.disable(Feature::MultiAgentV2);
|
||||||
|
harness
|
||||||
|
.config
|
||||||
|
.features
|
||||||
|
.set_enabled(Feature::GuardianThreadContext, thread_context_enabled)
|
||||||
|
.expect("test context mode");
|
||||||
let (parent_thread_id, parent_thread) = harness.start_paginated_thread().await;
|
let (parent_thread_id, parent_thread) = harness.start_paginated_thread().await;
|
||||||
let parent_usage = TokenUsage {
|
let parent_usage = TokenUsage {
|
||||||
total_tokens: 120,
|
total_tokens: 120,
|
||||||
@@ -1445,6 +1453,15 @@ async fn spawn_agent_fork_drops_inherited_token_usage_state() {
|
|||||||
total_tokens: 80,
|
total_tokens: 80,
|
||||||
..TokenUsage::default()
|
..TokenUsage::default()
|
||||||
};
|
};
|
||||||
|
assert!(
|
||||||
|
!child_thread
|
||||||
|
.session
|
||||||
|
.clone_history()
|
||||||
|
.await
|
||||||
|
.retained_context()
|
||||||
|
.user_messages_complete(),
|
||||||
|
"V1 forks lack complete retained authorization in both context modes"
|
||||||
|
);
|
||||||
let turn_context = child_thread.session.new_default_turn().await;
|
let turn_context = child_thread.session.new_default_turn().await;
|
||||||
child_thread
|
child_thread
|
||||||
.session
|
.session
|
||||||
@@ -2121,7 +2138,6 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history(
|
|||||||
);
|
);
|
||||||
let mut inherited_context = codex_history::RetainedContext::default();
|
let mut inherited_context = codex_history::RetainedContext::default();
|
||||||
if thread_context_enabled {
|
if thread_context_enabled {
|
||||||
inherited_context.restore(/*checkpoint*/ None);
|
|
||||||
inherited_context.reserve_order();
|
inherited_context.reserve_order();
|
||||||
} else {
|
} else {
|
||||||
inherited_context.mark_user_messages_incomplete();
|
inherited_context.mark_user_messages_incomplete();
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ struct SharedConversationHistory {
|
|||||||
items: Arc<Vec<ResponseItemEnvelope>>,
|
items: Arc<Vec<ResponseItemEnvelope>>,
|
||||||
review_history: Option<TranscriptHistory>,
|
review_history: Option<TranscriptHistory>,
|
||||||
retained_context: Arc<RetainedContext>,
|
retained_context: Arc<RetainedContext>,
|
||||||
|
expose_retained_context: bool,
|
||||||
history_version: u64,
|
history_version: u64,
|
||||||
user_message_revision: u64,
|
user_message_revision: u64,
|
||||||
}
|
}
|
||||||
@@ -112,7 +113,8 @@ impl ConversationHistorySnapshot for SharedConversationHistory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn retained_context(&self) -> Option<&RetainedContext> {
|
fn retained_context(&self) -> Option<&RetainedContext> {
|
||||||
Some(&self.retained_context)
|
self.expose_retained_context
|
||||||
|
.then_some(&self.retained_context)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn review_items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_> {
|
fn review_items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_> {
|
||||||
@@ -174,6 +176,7 @@ impl ContextManager {
|
|||||||
items: Arc::clone(&self.items),
|
items: Arc::clone(&self.items),
|
||||||
review_history: self.review_history.clone(),
|
review_history: self.review_history.clone(),
|
||||||
retained_context: Arc::clone(&self.retained_context),
|
retained_context: Arc::clone(&self.retained_context),
|
||||||
|
expose_retained_context: self.retain_user_messages,
|
||||||
history_version: self.history_version,
|
history_version: self.history_version,
|
||||||
user_message_revision: self.user_message_revision,
|
user_message_revision: self.user_message_revision,
|
||||||
})
|
})
|
||||||
@@ -329,7 +332,9 @@ impl ContextManager {
|
|||||||
}
|
}
|
||||||
Arc::make_mut(&mut self.items).push(processed);
|
Arc::make_mut(&mut self.items).push(processed);
|
||||||
if crate::context::is_user_authorization_message(item) {
|
if crate::context::is_user_authorization_message(item) {
|
||||||
if self.retain_user_messages
|
if !self.retain_user_messages {
|
||||||
|
Arc::make_mut(&mut self.retained_context).mark_user_messages_incomplete();
|
||||||
|
} else if !metadata.is_some_and(|metadata| metadata.inherited_user_message)
|
||||||
&& let ResponseItem::Message {
|
&& let ResponseItem::Message {
|
||||||
content,
|
content,
|
||||||
internal_chat_message_metadata_passthrough,
|
internal_chat_message_metadata_passthrough,
|
||||||
@@ -365,8 +370,6 @@ impl ContextManager {
|
|||||||
},
|
},
|
||||||
metadata.and_then(|metadata| metadata.user_input_order),
|
metadata.and_then(|metadata| metadata.user_input_order),
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
Arc::make_mut(&mut self.retained_context).mark_user_messages_incomplete();
|
|
||||||
}
|
}
|
||||||
self.user_message_revision = self.user_message_revision.saturating_add(1);
|
self.user_message_revision = self.user_message_revision.saturating_add(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -496,6 +496,10 @@ pub(super) fn collect_guardian_context(
|
|||||||
struct GuardianReviewHistory<'a>(&'a dyn ConversationHistorySnapshot);
|
struct GuardianReviewHistory<'a>(&'a dyn ConversationHistorySnapshot);
|
||||||
|
|
||||||
impl SectionHistory for GuardianReviewHistory<'_> {
|
impl SectionHistory for GuardianReviewHistory<'_> {
|
||||||
|
fn retained_context(&self) -> Option<&codex_history::RetainedContext> {
|
||||||
|
self.0.retained_context()
|
||||||
|
}
|
||||||
|
|
||||||
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_> {
|
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_> {
|
||||||
self.0.review_items()
|
self.0.review_items()
|
||||||
}
|
}
|
||||||
@@ -504,6 +508,10 @@ impl SectionHistory for GuardianReviewHistory<'_> {
|
|||||||
struct FilteredGuardianHistory<'a>(&'a dyn SectionHistory);
|
struct FilteredGuardianHistory<'a>(&'a dyn SectionHistory);
|
||||||
|
|
||||||
impl SectionHistory for FilteredGuardianHistory<'_> {
|
impl SectionHistory for FilteredGuardianHistory<'_> {
|
||||||
|
fn retained_context(&self) -> Option<&codex_history::RetainedContext> {
|
||||||
|
self.0.retained_context()
|
||||||
|
}
|
||||||
|
|
||||||
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_> {
|
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_> {
|
||||||
Box::new(self.0.items().filter(|item| {
|
Box::new(self.0.items().filter(|item| {
|
||||||
!matches!(
|
!matches!(
|
||||||
|
|||||||
@@ -8783,7 +8783,10 @@ where
|
|||||||
session_configuration.session_source.clone(),
|
session_configuration.session_source.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let state = SessionState::new(session_configuration.clone());
|
let mut state = SessionState::new(session_configuration.clone());
|
||||||
|
if config.features.enabled(Feature::GuardianThreadContext) {
|
||||||
|
state.history.enable_user_message_retention();
|
||||||
|
}
|
||||||
let (environment_manager, resolved_turn_environments) =
|
let (environment_manager, resolved_turn_environments) =
|
||||||
resolved_environments_for_configuration(&session_configuration, &default_environments)
|
resolved_environments_for_configuration(&session_configuration, &default_environments)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -196,16 +196,27 @@ async fn request_user_input_sets_non_blocking_outside_plan_mode(
|
|||||||
.expect("request_user_input handler should succeed");
|
.expect("request_user_input handler should succeed");
|
||||||
assert!(output.success_for_logging());
|
assert!(output.success_for_logging());
|
||||||
let history = session.conversation_history_snapshot().await;
|
let history = session.conversation_history_snapshot().await;
|
||||||
let RenderedVerifiedAnswers {
|
|
||||||
fragments,
|
|
||||||
complete,
|
|
||||||
} = codex_guardian_context::render_verified_answers(
|
|
||||||
history.retained_context().expect("host context snapshot"),
|
|
||||||
);
|
|
||||||
if thread_context_enabled {
|
if thread_context_enabled {
|
||||||
|
let RenderedVerifiedAnswers {
|
||||||
|
fragments,
|
||||||
|
complete,
|
||||||
|
} = codex_guardian_context::render_verified_answers(
|
||||||
|
history.retained_context().expect("host context snapshot"),
|
||||||
|
);
|
||||||
|
let expected_fragments = expected
|
||||||
|
.fragments
|
||||||
|
.iter()
|
||||||
|
.map(|fragment| {
|
||||||
|
if expected.complete {
|
||||||
|
format!("Retained source order: 0\n{fragment}")
|
||||||
|
} else {
|
||||||
|
fragment.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(&fragments, complete),
|
(&fragments, complete),
|
||||||
(&expected.fragments, expected.complete)
|
(&expected_fragments, expected.complete)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -239,7 +250,11 @@ async fn request_user_input_sets_non_blocking_outside_plan_mode(
|
|||||||
}
|
}
|
||||||
_ => assert_eq!(
|
_ => assert_eq!(
|
||||||
legacy_answer,
|
legacy_answer,
|
||||||
fragments.first().cloned().filter(|_| complete)
|
expected
|
||||||
|
.fragments
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.filter(|_| expected.complete)
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,18 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use codex_core::CodexThread;
|
use codex_core::CodexThread;
|
||||||
use codex_core::TurnInputRequest;
|
use codex_core::TurnInputRequest;
|
||||||
|
use codex_core::config::Config;
|
||||||
use codex_core::config::ThreadStoreConfig;
|
use codex_core::config::ThreadStoreConfig;
|
||||||
|
use codex_extension_api::ExtensionRegistryBuilder;
|
||||||
|
use codex_extension_api::ToolLifecycleContributor;
|
||||||
|
use codex_extension_api::ToolLifecycleFuture;
|
||||||
|
use codex_extension_api::ToolStartInput;
|
||||||
use codex_features::Feature;
|
use codex_features::Feature;
|
||||||
use codex_history::InitialHistory;
|
use codex_history::InitialHistory;
|
||||||
use codex_history::ResumedHistory;
|
use codex_history::ResumedHistory;
|
||||||
@@ -33,6 +39,7 @@ use core_test_support::responses::ev_assistant_message;
|
|||||||
use core_test_support::responses::ev_completed;
|
use core_test_support::responses::ev_completed;
|
||||||
use core_test_support::responses::ev_function_call;
|
use core_test_support::responses::ev_function_call;
|
||||||
use core_test_support::responses::ev_function_call_with_namespace;
|
use core_test_support::responses::ev_function_call_with_namespace;
|
||||||
|
use core_test_support::responses::mount_sse_once_match;
|
||||||
use core_test_support::responses::mount_sse_sequence;
|
use core_test_support::responses::mount_sse_sequence;
|
||||||
use core_test_support::responses::sse;
|
use core_test_support::responses::sse;
|
||||||
use core_test_support::responses::start_mock_server;
|
use core_test_support::responses::start_mock_server;
|
||||||
@@ -44,7 +51,27 @@ use core_test_support::wait_for_event_match;
|
|||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use test_case::test_case;
|
use test_case::test_case;
|
||||||
|
use tokio::sync::Notify;
|
||||||
use wiremock::MockServer;
|
use wiremock::MockServer;
|
||||||
|
use wiremock::matchers::header;
|
||||||
|
|
||||||
|
// Keep child completion out of the parent's history while checking rollback boundaries.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ChildToolGate {
|
||||||
|
entered: Notify,
|
||||||
|
release: Notify,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToolLifecycleContributor for ChildToolGate {
|
||||||
|
fn on_tool_start<'a>(&'a self, input: ToolStartInput<'a>) -> ToolLifecycleFuture<'a> {
|
||||||
|
Box::pin(async move {
|
||||||
|
if input.call_id == "child-pause" {
|
||||||
|
self.entered.notify_one();
|
||||||
|
self.release.notified().await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn record_answer(
|
async fn record_answer(
|
||||||
thread: &CodexThread,
|
thread: &CodexThread,
|
||||||
@@ -378,7 +405,14 @@ async fn retained_instructions_keep_identity_across_compaction_and_resume(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert_eq!(serde_json::to_value(history.retained_context())?, expected);
|
assert_eq!(
|
||||||
|
serde_json::to_value(history.retained_context())?,
|
||||||
|
if thread_context_enabled {
|
||||||
|
expected.clone()
|
||||||
|
} else {
|
||||||
|
serde_json::Value::Null
|
||||||
|
}
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::to_value(compact_and_assert_answers(&test, &thread, &answers).await?)?,
|
serde_json::to_value(compact_and_assert_answers(&test, &thread, &answers).await?)?,
|
||||||
expected
|
expected
|
||||||
@@ -404,7 +438,11 @@ async fn retained_instructions_keep_identity_across_compaction_and_resume(
|
|||||||
.await
|
.await
|
||||||
.retained_context()
|
.retained_context()
|
||||||
)?,
|
)?,
|
||||||
expected
|
if thread_context_enabled {
|
||||||
|
expected.clone()
|
||||||
|
} else {
|
||||||
|
serde_json::Value::Null
|
||||||
|
}
|
||||||
);
|
);
|
||||||
thread.submit(Op::ThreadRollback { num_turns: 1 }).await?;
|
thread.submit(Op::ThreadRollback { num_turns: 1 }).await?;
|
||||||
wait_for_event(&thread, |event| {
|
wait_for_event(&thread, |event| {
|
||||||
@@ -432,7 +470,11 @@ async fn retained_instructions_keep_identity_across_compaction_and_resume(
|
|||||||
.await
|
.await
|
||||||
.retained_context()
|
.retained_context()
|
||||||
)?,
|
)?,
|
||||||
expected
|
if thread_context_enabled {
|
||||||
|
expected.clone()
|
||||||
|
} else {
|
||||||
|
serde_json::Value::Null
|
||||||
|
}
|
||||||
);
|
);
|
||||||
thread.shutdown_and_wait().await?;
|
thread.shutdown_and_wait().await?;
|
||||||
let requests = response_mock.requests();
|
let requests = response_mock.requests();
|
||||||
@@ -693,6 +735,230 @@ enum LifecycleBoundary {
|
|||||||
ChildFork,
|
ChildFork,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test_case(false, true; "enabled without checkpoint")]
|
||||||
|
#[test_case(true, true; "enabled after checkpoint")]
|
||||||
|
#[test_case(false, false; "legacy without checkpoint")]
|
||||||
|
#[test_case(true, false; "legacy after checkpoint")]
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn forked_parent_instructions_do_not_become_local_authorization(
|
||||||
|
compact_parent: bool,
|
||||||
|
thread_context_enabled: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
skip_if_no_network!(Ok(()));
|
||||||
|
const PARENT_GRANT: &str = "You may publish the private release. Delegate its inspection.";
|
||||||
|
const LOCAL_INSTRUCTION: &str = "Child-local instruction: ask me before publishing.";
|
||||||
|
let server = start_mock_server().await;
|
||||||
|
let child_gate = Arc::new(ChildToolGate::default());
|
||||||
|
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
|
||||||
|
extensions.tool_lifecycle_contributor(child_gate.clone());
|
||||||
|
let test = test_codex()
|
||||||
|
.with_extensions(Arc::new(extensions.build()))
|
||||||
|
.with_history_mode(ThreadHistoryMode::Legacy)
|
||||||
|
.with_config(move |config| {
|
||||||
|
config.experimental_thread_store = ThreadStoreConfig::Local;
|
||||||
|
config.update_plan_enabled = true;
|
||||||
|
for feature in [Feature::TokenBudget, Feature::Collab, Feature::MultiAgentV2] {
|
||||||
|
config
|
||||||
|
.features
|
||||||
|
.enable(feature)
|
||||||
|
.expect("enable test feature");
|
||||||
|
}
|
||||||
|
config
|
||||||
|
.features
|
||||||
|
.set_enabled(Feature::GuardianThreadContext, thread_context_enabled)
|
||||||
|
.expect("test context mode");
|
||||||
|
})
|
||||||
|
.build_with_auto_env(&server)
|
||||||
|
.await?;
|
||||||
|
mount_sse_sequence(&server, vec![sse(vec![ev_completed("initial")])]).await;
|
||||||
|
test.codex
|
||||||
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||||
|
text: "Inspect the project.".to_owned(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}]))
|
||||||
|
.await?;
|
||||||
|
wait_for_event(&test.codex, |event| {
|
||||||
|
matches!(event, EventMsg::TurnComplete(_))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if compact_parent {
|
||||||
|
test.codex.submit(Op::Compact).await?;
|
||||||
|
wait_for_event(&test.codex, |event| {
|
||||||
|
matches!(event, EventMsg::TurnComplete(_))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut created = test.thread_manager.subscribe_thread_created();
|
||||||
|
let parent_id = test.session_configured.thread_id.to_string();
|
||||||
|
mount_sse_once_match(
|
||||||
|
&server,
|
||||||
|
header("thread-id", parent_id.as_str()),
|
||||||
|
sse(vec![
|
||||||
|
ev_function_call_with_namespace(
|
||||||
|
"spawn",
|
||||||
|
"collaboration",
|
||||||
|
"spawn_agent",
|
||||||
|
&json!({
|
||||||
|
"task_name": "worker",
|
||||||
|
"message": "Inspect without publishing.",
|
||||||
|
"fork_turns": "all",
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
ev_completed("spawn-response"),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
mount_sse_once_match(
|
||||||
|
&server,
|
||||||
|
header("thread-id", parent_id.as_str()),
|
||||||
|
sse(vec![ev_completed("parent-fork-completion")]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// Hold the child at a real tool boundary until rollback is checked. Its completion
|
||||||
|
// notification must not add a parent message or consume a parent mock response.
|
||||||
|
let child_requests = mount_sse_once_match(
|
||||||
|
&server,
|
||||||
|
move |request: &wiremock::Request| {
|
||||||
|
request
|
||||||
|
.headers
|
||||||
|
.get("thread-id")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|thread_id| thread_id != parent_id)
|
||||||
|
},
|
||||||
|
sse(vec![
|
||||||
|
ev_function_call(
|
||||||
|
"child-pause",
|
||||||
|
"update_plan",
|
||||||
|
&json!({"plan": [{"step": "Inspect the project", "status": "in_progress"}]})
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
ev_completed("child-paused"),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// This grant is either in uncompacted history or in the checkpoint's replay suffix.
|
||||||
|
test.codex
|
||||||
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||||
|
text: PARENT_GRANT.to_owned(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}]))
|
||||||
|
.await?;
|
||||||
|
wait_for_event(&test.codex, |event| {
|
||||||
|
matches!(event, EventMsg::TurnComplete(_))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let child = test.thread_manager.get_thread(created.try_recv()?).await?;
|
||||||
|
tokio::time::timeout(Duration::from_secs(30), child_gate.entered.notified())
|
||||||
|
.await
|
||||||
|
.context("child did not reach the paused tool call")?;
|
||||||
|
let child_id = child.session_configured().thread_id.to_string();
|
||||||
|
let requests = child_requests.requests();
|
||||||
|
let child_request = requests
|
||||||
|
.iter()
|
||||||
|
.find(|request| request.body_json()["client_metadata"]["thread_id"] == child_id)
|
||||||
|
.context("child model request")?;
|
||||||
|
assert!(serde_json::to_string(&child_request.input())?.contains(PARENT_GRANT));
|
||||||
|
let history = child.conversation_history_snapshot().await;
|
||||||
|
assert_eq!(
|
||||||
|
history.retained_context().map(|context| (
|
||||||
|
context.ordered_entries().count(),
|
||||||
|
context.user_messages_complete()
|
||||||
|
)),
|
||||||
|
thread_context_enabled.then_some((0, true)),
|
||||||
|
"inherited conversation must not populate child-local authorization",
|
||||||
|
);
|
||||||
|
let root_before = child.guardian_root_snapshot().await.context("live root")?;
|
||||||
|
assert!(
|
||||||
|
root_before
|
||||||
|
.messages
|
||||||
|
.contains(&codex_core::GuardianRootMessage::User(
|
||||||
|
PARENT_GRANT.to_owned()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
test.codex
|
||||||
|
.submit(Op::ThreadRollback { num_turns: 1 })
|
||||||
|
.await?;
|
||||||
|
wait_for_event(&test.codex, |event| {
|
||||||
|
matches!(event, EventMsg::ThreadRolledBack(_))
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let root_after = child
|
||||||
|
.guardian_root_snapshot()
|
||||||
|
.await
|
||||||
|
.context("live root after rollback")?;
|
||||||
|
assert_ne!(
|
||||||
|
root_before.authorization_version,
|
||||||
|
root_after.authorization_version
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!root_after
|
||||||
|
.messages
|
||||||
|
.contains(&codex_core::GuardianRootMessage::User(
|
||||||
|
PARENT_GRANT.to_owned()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
mount_sse_once_match(
|
||||||
|
&server,
|
||||||
|
header("thread-id", child_id.as_str()),
|
||||||
|
sse(vec![ev_completed("child-fork-completion")]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
child_gate.release.notify_one();
|
||||||
|
wait_for_event(&child, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||||
|
mount_sse_once_match(
|
||||||
|
&server,
|
||||||
|
header("thread-id", child_id.as_str()),
|
||||||
|
sse(vec![ev_completed("local-instruction")]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
child
|
||||||
|
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
|
||||||
|
text: LOCAL_INSTRUCTION.to_owned(),
|
||||||
|
text_elements: Vec::new(),
|
||||||
|
}]))
|
||||||
|
.await?;
|
||||||
|
wait_for_event(&child, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||||
|
let history = child.conversation_history_snapshot().await;
|
||||||
|
let expected = history.retained_context().cloned();
|
||||||
|
assert_eq!(
|
||||||
|
expected.as_ref().map(|context| context
|
||||||
|
.ordered_entries()
|
||||||
|
.map(|entry| match entry {
|
||||||
|
codex_history::RetainedContextEntry::UserMessage(message) => message.text.as_str(),
|
||||||
|
codex_history::RetainedContextEntry::VerifiedAnswer(_) =>
|
||||||
|
panic!("unexpected answer"),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()),
|
||||||
|
thread_context_enabled.then_some(vec![LOCAL_INSTRUCTION]),
|
||||||
|
"genuine child-local instructions must still be captured",
|
||||||
|
);
|
||||||
|
let child = resume(&test, &child).await?;
|
||||||
|
assert_eq!(
|
||||||
|
child
|
||||||
|
.conversation_history_snapshot()
|
||||||
|
.await
|
||||||
|
.retained_context(),
|
||||||
|
expected.as_ref()
|
||||||
|
);
|
||||||
|
child.submit(Op::Compact).await?;
|
||||||
|
wait_for_event(&child, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||||
|
let child = resume(&test, &child).await?;
|
||||||
|
assert_eq!(
|
||||||
|
child
|
||||||
|
.conversation_history_snapshot()
|
||||||
|
.await
|
||||||
|
.retained_context(),
|
||||||
|
expected.as_ref()
|
||||||
|
);
|
||||||
|
child.shutdown_and_wait().await?;
|
||||||
|
test.codex.shutdown_and_wait().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test_case(ThreadHistoryMode::Legacy, LifecycleBoundary::Rollback; "legacy rollback")]
|
#[test_case(ThreadHistoryMode::Legacy, LifecycleBoundary::Rollback; "legacy rollback")]
|
||||||
#[test_case(ThreadHistoryMode::Legacy, LifecycleBoundary::ChildFork; "legacy child fork")]
|
#[test_case(ThreadHistoryMode::Legacy, LifecycleBoundary::ChildFork; "legacy child fork")]
|
||||||
#[test_case(ThreadHistoryMode::Paginated, LifecycleBoundary::ChildFork; "paginated child fork")]
|
#[test_case(ThreadHistoryMode::Paginated, LifecycleBoundary::ChildFork; "paginated child fork")]
|
||||||
|
|||||||
@@ -2760,7 +2760,8 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let history = test.codex.conversation_history_snapshot().await;
|
let history = test.codex.conversation_history_snapshot().await;
|
||||||
let retained = serde_json::to_value(history.retained_context())?;
|
assert_eq!(history.retained_context().is_some(), thread_context_enabled);
|
||||||
|
let retained = serde_json::to_value(history.retained_context().cloned().unwrap_or_default())?;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
retained["user_messages"]
|
retained["user_messages"]
|
||||||
.as_array()
|
.as_array()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub trait ConversationHistorySnapshot: Send + Sync {
|
|||||||
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_>;
|
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_>;
|
||||||
|
|
||||||
/// Host-owned retained facts captured atomically with the parent model window.
|
/// Host-owned retained facts captured atomically with the parent model window.
|
||||||
|
/// Legacy hosts can withhold these facts to preserve their existing reviewer policy.
|
||||||
fn retained_context(&self) -> Option<&RetainedContext> {
|
fn retained_context(&self) -> Option<&RetainedContext> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -373,6 +373,23 @@ impl ExtensionMetrics for RecordingMetrics {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn user_instruction(text: &str) -> ResponseItem {
|
||||||
|
ResponseItem::Message {
|
||||||
|
id: Some(ResponseItemId::new("msg")),
|
||||||
|
role: "user".to_owned(),
|
||||||
|
content: vec![ContentItem::InputText {
|
||||||
|
text: text.to_owned(),
|
||||||
|
}],
|
||||||
|
phase: None,
|
||||||
|
internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough {
|
||||||
|
content_item_kinds: Some(vec![codex_protocol::models::ContentItemKind(
|
||||||
|
"user.text".to_owned(),
|
||||||
|
)]),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct TestConversationHistory(Vec<ResponseItem>);
|
struct TestConversationHistory(Vec<ResponseItem>);
|
||||||
|
|
||||||
struct TestRetainedHistory {
|
struct TestRetainedHistory {
|
||||||
@@ -1089,7 +1106,7 @@ async fn sample_configured_conversation_history_with_source(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
let conversation_history = TestConversationHistory(conversation_history);
|
let conversation_history = test.codex.conversation_history_snapshot().await;
|
||||||
|
|
||||||
registry.tool_lifecycle_contributors()[0]
|
registry.tool_lifecycle_contributors()[0]
|
||||||
.on_tool_start(ToolStartInput {
|
.on_tool_start(ToolStartInput {
|
||||||
@@ -1102,7 +1119,7 @@ async fn sample_configured_conversation_history_with_source(
|
|||||||
tool_name: &tool_name,
|
tool_name: &tool_name,
|
||||||
mcp_tool: None,
|
mcp_tool: None,
|
||||||
payload: &tool_payload,
|
payload: &tool_payload,
|
||||||
conversation_history: Arc::new(conversation_history),
|
conversation_history,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
@@ -1491,15 +1508,7 @@ max_tool_transcript_tokens = 128
|
|||||||
max_recent_non_user_entries = 8
|
max_recent_non_user_entries = 8
|
||||||
"#;
|
"#;
|
||||||
let conversation_history = vec![
|
let conversation_history = vec![
|
||||||
ResponseItem::Message {
|
user_instruction("Review the pending action."),
|
||||||
id: None,
|
|
||||||
role: "user".to_owned(),
|
|
||||||
content: vec![ContentItem::InputText {
|
|
||||||
text: "Review the pending action.".to_owned(),
|
|
||||||
}],
|
|
||||||
phase: None,
|
|
||||||
internal_chat_message_metadata_passthrough: None,
|
|
||||||
},
|
|
||||||
ResponseItem::Reasoning {
|
ResponseItem::Reasoning {
|
||||||
id: None,
|
id: None,
|
||||||
summary: vec![ReasoningItemReasoningSummary::SummaryText {
|
summary: vec![ReasoningItemReasoningSummary::SummaryText {
|
||||||
@@ -1812,6 +1821,8 @@ max_recent_non_user_entries = 8
|
|||||||
async fn contributor_includes_transcript_images_by_default() -> Result<()> {
|
async fn contributor_includes_transcript_images_by_default() -> Result<()> {
|
||||||
skip_if_no_network!(Ok(()));
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
|
let user_image = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGPgEpEDAABoAD1UCKP3AAAAAElFTkSuQmCC";
|
||||||
|
let tool_image = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGOQE+ECAACQAD304kFaAAAAAElFTkSuQmCC";
|
||||||
let history = vec![
|
let history = vec![
|
||||||
ResponseItem::Message {
|
ResponseItem::Message {
|
||||||
id: None,
|
id: None,
|
||||||
@@ -1821,13 +1832,22 @@ async fn contributor_includes_transcript_images_by_default() -> Result<()> {
|
|||||||
text: "Review what is shown on screen.".to_owned(),
|
text: "Review what is shown on screen.".to_owned(),
|
||||||
},
|
},
|
||||||
ContentItem::InputImage {
|
ContentItem::InputImage {
|
||||||
image_url: "data:image/png;base64,user-screenshot".to_owned(),
|
image_url: user_image.to_owned(),
|
||||||
detail: Some(ImageDetail::High),
|
detail: Some(ImageDetail::High),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
phase: None,
|
phase: None,
|
||||||
internal_chat_message_metadata_passthrough: None,
|
internal_chat_message_metadata_passthrough: None,
|
||||||
},
|
},
|
||||||
|
ResponseItem::FunctionCall {
|
||||||
|
id: None,
|
||||||
|
name: "screenshot".to_owned(),
|
||||||
|
namespace: None,
|
||||||
|
arguments: "{}".to_owned(),
|
||||||
|
encrypted_function_args: None,
|
||||||
|
call_id: "previous-call".to_owned(),
|
||||||
|
internal_chat_message_metadata_passthrough: None,
|
||||||
|
},
|
||||||
ResponseItem::FunctionCallOutput {
|
ResponseItem::FunctionCallOutput {
|
||||||
id: None,
|
id: None,
|
||||||
call_id: Some("previous-call".to_owned()),
|
call_id: Some("previous-call".to_owned()),
|
||||||
@@ -1838,8 +1858,8 @@ async fn contributor_includes_transcript_images_by_default() -> Result<()> {
|
|||||||
text: "Screenshot captured.".to_owned(),
|
text: "Screenshot captured.".to_owned(),
|
||||||
},
|
},
|
||||||
FunctionCallOutputContentItem::InputImage {
|
FunctionCallOutputContentItem::InputImage {
|
||||||
image_url: "data:image/png;base64,tool-screenshot".to_owned(),
|
image_url: tool_image.to_owned(),
|
||||||
detail: Some(ImageDetail::Low),
|
detail: Some(ImageDetail::High),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
internal_chat_message_metadata_passthrough: None,
|
internal_chat_message_metadata_passthrough: None,
|
||||||
@@ -1866,11 +1886,11 @@ enabled = true
|
|||||||
[
|
[
|
||||||
json!({
|
json!({
|
||||||
"type": "input_image",
|
"type": "input_image",
|
||||||
"image_url": "data:image/png;base64,user-screenshot",
|
"image_url": user_image,
|
||||||
}),
|
}),
|
||||||
json!({
|
json!({
|
||||||
"type": "input_image",
|
"type": "input_image",
|
||||||
"image_url": "data:image/png;base64,tool-screenshot",
|
"image_url": tool_image,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -1900,15 +1920,7 @@ async fn contributor_uses_model_defaults_and_preserves_local_overrides() -> Resu
|
|||||||
max_parent_compaction_tokens: Some(384),
|
max_parent_compaction_tokens: Some(384),
|
||||||
};
|
};
|
||||||
let conversation_history = vec![
|
let conversation_history = vec![
|
||||||
ResponseItem::Message {
|
user_instruction("Review the pending action."),
|
||||||
id: None,
|
|
||||||
role: "user".to_owned(),
|
|
||||||
content: vec![ContentItem::InputText {
|
|
||||||
text: "Review the pending action.".to_owned(),
|
|
||||||
}],
|
|
||||||
phase: None,
|
|
||||||
internal_chat_message_metadata_passthrough: None,
|
|
||||||
},
|
|
||||||
ResponseItem::Reasoning {
|
ResponseItem::Reasoning {
|
||||||
id: None,
|
id: None,
|
||||||
summary: vec![ReasoningItemReasoningSummary::SummaryText {
|
summary: vec![ReasoningItemReasoningSummary::SummaryText {
|
||||||
@@ -2019,18 +2031,19 @@ async fn contributor_uses_model_defaults_and_preserves_local_overrides() -> Resu
|
|||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result<()> {
|
async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result<()> {
|
||||||
|
assert_luna_pool_context(/*thread_context_enabled*/ true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn legacy_contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result<()> {
|
||||||
|
assert_luna_pool_context(/*thread_context_enabled*/ false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_luna_pool_context(thread_context_enabled: bool) -> Result<()> {
|
||||||
skip_if_no_network!(Ok(()));
|
skip_if_no_network!(Ok(()));
|
||||||
|
|
||||||
let conversation_history = vec![
|
let conversation_history = vec![
|
||||||
ResponseItem::Message {
|
user_instruction("Inspect the repository guidelines."),
|
||||||
id: None,
|
|
||||||
role: "user".to_owned(),
|
|
||||||
content: vec![ContentItem::InputText {
|
|
||||||
text: "Inspect the repository guidelines.".to_owned(),
|
|
||||||
}],
|
|
||||||
phase: None,
|
|
||||||
internal_chat_message_metadata_passthrough: None,
|
|
||||||
},
|
|
||||||
ResponseItem::Reasoning {
|
ResponseItem::Reasoning {
|
||||||
id: None,
|
id: None,
|
||||||
summary: vec![ReasoningItemReasoningSummary::SummaryText {
|
summary: vec![ReasoningItemReasoningSummary::SummaryText {
|
||||||
@@ -2067,10 +2080,12 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result<
|
|||||||
internal_chat_message_metadata_passthrough: None,
|
internal_chat_message_metadata_passthrough: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let (request, test, registry) = sample_conversation_history(
|
let (request, test, registry) = sample_configured_conversation_history(
|
||||||
conversation_history,
|
conversation_history,
|
||||||
r#"{"path":"README.md"}"#,
|
r#"{"path":"README.md"}"#,
|
||||||
Some(TEST_GUARDIAN_POLICY),
|
Some(TEST_GUARDIAN_POLICY),
|
||||||
|
&format!("[features]\nguardian_thread_context = {thread_context_enabled}\n"),
|
||||||
|
/*model_defaults*/ None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let thread_id = test.session_configured.thread_id;
|
let thread_id = test.session_configured.thread_id;
|
||||||
@@ -2126,28 +2141,35 @@ async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result<
|
|||||||
}],
|
}],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(
|
let mut expected_content = json!([
|
||||||
request["input"][2]["content"],
|
{"type": "input_text", "text": ">>> 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"},
|
||||||
json!([
|
{"type": "input_text", "text": "Retained source order: 0\nuser: Inspect the repository guidelines.\n"},
|
||||||
{"type": "input_text", "text": ">>> TRANSCRIPT START\n"},
|
{"type": "input_text", "text": ">>> RETAINED USER INSTRUCTIONS END\n"},
|
||||||
{"type": "input_text", "text": "[1] user: Inspect the repository guidelines.\n"},
|
{"type": "input_text", "text": ">>> TRANSCRIPT START\n"},
|
||||||
{"type": "input_text", "text": "[2] tool list_dir call: {\"path\":\".\"}\n"},
|
{"type": "input_text", "text": "[1] user: Inspect the repository guidelines.\n"},
|
||||||
{"type": "input_text", "text": "[3] tool list_dir result: README.md\n"},
|
{"type": "input_text", "text": "[2] tool list_dir call: {\"path\":\".\"}\n"},
|
||||||
{"type": "input_text", "text": "[4] tool read_file call: {\"path\":\"README.md\"}\n"},
|
{"type": "input_text", "text": "[3] tool list_dir result: README.md\n"},
|
||||||
{"type": "input_text", "text": ">>> TRANSCRIPT END\n\n"},
|
{"type": "input_text", "text": "[4] 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": "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": ">>> 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": "{\n \"path\": \"README.md\",\n \"tool\": \"read_file\"\n}\n"
|
||||||
{"type": "input_text", "text": ">>> APPROVAL REQUEST END\n"},
|
},
|
||||||
])
|
{"type": "input_text", "text": ">>> APPROVAL REQUEST END\n"},
|
||||||
);
|
]);
|
||||||
|
if !thread_context_enabled {
|
||||||
|
expected_content
|
||||||
|
.as_array_mut()
|
||||||
|
.expect("content array")
|
||||||
|
.drain(..3);
|
||||||
|
}
|
||||||
|
assert_eq!(request["input"][2]["content"], expected_content);
|
||||||
let score = tokio::time::timeout(ASYNC_TEST_TIMEOUT, async {
|
let score = tokio::time::timeout(ASYNC_TEST_TIMEOUT, async {
|
||||||
loop {
|
loop {
|
||||||
if let Some(score) = thread_store.get::<SecurityRiskScore>() {
|
if let Some(score) = thread_store.get::<SecurityRiskScore>() {
|
||||||
@@ -3154,15 +3176,7 @@ async fn assert_parent_compaction_reuse(thread_context_enabled: bool) -> Result<
|
|||||||
internal_chat_message_metadata_passthrough: None,
|
internal_chat_message_metadata_passthrough: None,
|
||||||
},
|
},
|
||||||
latest_compaction.clone(),
|
latest_compaction.clone(),
|
||||||
ResponseItem::Message {
|
user_instruction("Inspect the repository guidelines."),
|
||||||
id: None,
|
|
||||||
role: "user".to_owned(),
|
|
||||||
content: vec![ContentItem::InputText {
|
|
||||||
text: "Inspect the repository guidelines.".to_owned(),
|
|
||||||
}],
|
|
||||||
phase: None,
|
|
||||||
internal_chat_message_metadata_passthrough: None,
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Box::pin(
|
Box::pin(
|
||||||
@@ -3331,15 +3345,7 @@ async fn contributor_can_disable_parent_compaction_reuse() -> Result<()> {
|
|||||||
};
|
};
|
||||||
let conversation_history = vec![
|
let conversation_history = vec![
|
||||||
oversized_compaction,
|
oversized_compaction,
|
||||||
ResponseItem::Message {
|
user_instruction("Inspect the repository guidelines."),
|
||||||
id: None,
|
|
||||||
role: "user".to_owned(),
|
|
||||||
content: vec![ContentItem::InputText {
|
|
||||||
text: "Inspect the repository guidelines.".to_owned(),
|
|
||||||
}],
|
|
||||||
phase: None,
|
|
||||||
internal_chat_message_metadata_passthrough: None,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
let configuration = "[features]\nguardian_thread_context = true\n\n[features.guardianv2]\nenabled = true\nreuse_parent_compaction = false\nmax_parent_compaction_tokens = 256\n";
|
let configuration = "[features]\nguardian_thread_context = true\n\n[features.guardianv2]\nenabled = true\nreuse_parent_compaction = false\nmax_parent_compaction_tokens = 256\n";
|
||||||
let (request, test, _registry) = sample_configured_conversation_history(
|
let (request, test, _registry) = sample_configured_conversation_history(
|
||||||
|
|||||||
@@ -311,6 +311,10 @@ impl TranscriptConfig {
|
|||||||
struct SnapshotHistory<'a>(&'a dyn ConversationHistorySnapshot);
|
struct SnapshotHistory<'a>(&'a dyn ConversationHistorySnapshot);
|
||||||
|
|
||||||
impl SectionHistory for SnapshotHistory<'_> {
|
impl SectionHistory for SnapshotHistory<'_> {
|
||||||
|
fn retained_context(&self) -> Option<&codex_history::RetainedContext> {
|
||||||
|
self.0.retained_context()
|
||||||
|
}
|
||||||
|
|
||||||
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_> {
|
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_> {
|
||||||
self.0.review_items()
|
self.0.review_items()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ impl SectionRegistry {
|
|||||||
context.transcript.extend(items)
|
context.transcript.extend(items)
|
||||||
}
|
}
|
||||||
ContextSection::RootConversation { items }
|
ContextSection::RootConversation { items }
|
||||||
|
| ContextSection::RetainedUserInstructions { items }
|
||||||
| ContextSection::TrustedUserAnswers { items } => {
|
| ContextSection::TrustedUserAnswers { items } => {
|
||||||
context.authorization.extend(items)
|
context.authorization.extend(items)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use codex_protocol::models::ResponseItem;
|
|||||||
|
|
||||||
use authorization::RootConversationSection;
|
use authorization::RootConversationSection;
|
||||||
use authorization::TrustedUserAnswersSection;
|
use authorization::TrustedUserAnswersSection;
|
||||||
|
use retained_instructions::RetainedUserInstructionsSection;
|
||||||
use transcript::ConversationTranscriptSection;
|
use transcript::ConversationTranscriptSection;
|
||||||
|
|
||||||
pub use authorization::GuardianRootMessage;
|
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_answer;
|
||||||
pub use verified_answers::render_verified_answers;
|
pub use verified_answers::render_verified_answers;
|
||||||
|
|
||||||
|
mod retained_instructions;
|
||||||
|
|
||||||
mod authorization;
|
mod authorization;
|
||||||
mod composition;
|
mod composition;
|
||||||
mod entry;
|
mod entry;
|
||||||
@@ -102,6 +105,11 @@ pub struct SectionInput<'a> {
|
|||||||
pub trait SectionHistory: Send + Sync {
|
pub trait SectionHistory: Send + Sync {
|
||||||
/// Returns borrowed response items in their original conversation order.
|
/// Returns borrowed response items in their original conversation order.
|
||||||
fn items(&self) -> Box<dyn Iterator<Item = &ResponseItem> + Send + '_>;
|
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> {
|
impl SectionHistory for Vec<ResponseItem> {
|
||||||
@@ -170,6 +178,7 @@ pub fn default_registry() -> &'static SectionRegistry {
|
|||||||
static REGISTRY: LazyLock<SectionRegistry> = LazyLock::new(|| {
|
static REGISTRY: LazyLock<SectionRegistry> = LazyLock::new(|| {
|
||||||
let mut registry = SectionRegistry::default();
|
let mut registry = SectionRegistry::default();
|
||||||
registry.register(RootConversationSection);
|
registry.register(RootConversationSection);
|
||||||
|
registry.register(RetainedUserInstructionsSection);
|
||||||
registry.register(TrustedUserAnswersSection);
|
registry.register(TrustedUserAnswersSection);
|
||||||
registry.register(ConversationTranscriptSection);
|
registry.register(ConversationTranscriptSection);
|
||||||
registry
|
registry
|
||||||
@@ -213,6 +222,9 @@ pub enum ContextSection {
|
|||||||
TrustedUserAnswers {
|
TrustedUserAnswers {
|
||||||
items: Vec<String>,
|
items: Vec<String>,
|
||||||
},
|
},
|
||||||
|
RetainedUserInstructions {
|
||||||
|
items: Vec<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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.
|
//! Omit a record atomically when it cannot fit; never truncate a restriction out of a grant.
|
||||||
|
|
||||||
use codex_history::RetainedContext;
|
use codex_history::RetainedContext;
|
||||||
|
use codex_history::RetainedContextEntry;
|
||||||
use codex_history::VerifiedAnswer;
|
use codex_history::VerifiedAnswer;
|
||||||
|
|
||||||
use crate::GuardianRootMessage;
|
use crate::GuardianRootMessage;
|
||||||
@@ -19,9 +20,13 @@ pub struct RenderedVerifiedAnswers {
|
|||||||
pub fn render_verified_answers(context: &RetainedContext) -> RenderedVerifiedAnswers {
|
pub fn render_verified_answers(context: &RetainedContext) -> RenderedVerifiedAnswers {
|
||||||
let mut complete = context.verified_answers_complete();
|
let mut complete = context.verified_answers_complete();
|
||||||
let mut fragments = Vec::new();
|
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) {
|
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 {
|
} else {
|
||||||
complete = false;
|
complete = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ pub struct CodexHarnessMetadata {
|
|||||||
/// Thread acceptance order, independent of when queued user input reaches model history.
|
/// Thread acceptance order, independent of when queued user input reaches model history.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub user_input_order: Option<u64>,
|
pub user_input_order: Option<u64>,
|
||||||
|
|
||||||
|
/// Copied parent context stays model-visible but must not become child-local authorization.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub inherited_user_message: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResponseItemEnvelope {
|
impl ResponseItemEnvelope {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ fn response_item_envelope_stores_metadata_beside_rollout_payload() -> Result<()>
|
|||||||
metadata: Some(CodexHarnessMetadata {
|
metadata: Some(CodexHarnessMetadata {
|
||||||
client_authored: true,
|
client_authored: true,
|
||||||
fallback_token_limit_override: Some(20_000),
|
fallback_token_limit_override: Some(20_000),
|
||||||
|
inherited_user_message: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -92,7 +93,11 @@ fn response_item_envelope_stores_metadata_beside_rollout_payload() -> Result<()>
|
|||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"type": "response_item",
|
"type": "response_item",
|
||||||
"payload": response_item,
|
"payload": response_item,
|
||||||
"metadata": { "client_authored": true, "fallback_token_limit_override": 20_000 },
|
"metadata": {
|
||||||
|
"client_authored": true,
|
||||||
|
"fallback_token_limit_override": 20_000,
|
||||||
|
"inherited_user_message": true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(serialized["payload"].get("metadata"), None);
|
assert_eq!(serialized["payload"].get("metadata"), None);
|
||||||
@@ -106,6 +111,7 @@ fn response_item_envelope_stores_metadata_beside_rollout_payload() -> Result<()>
|
|||||||
Some(CodexHarnessMetadata {
|
Some(CodexHarnessMetadata {
|
||||||
client_authored: true,
|
client_authored: true,
|
||||||
fallback_token_limit_override: Some(20_000),
|
fallback_token_limit_override: Some(20_000),
|
||||||
|
inherited_user_message: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user