Strip parent startup context from forked history

This commit is contained in:
jif-oai
2026-05-17 15:12:01 +02:00
parent 2d7fb209cc
commit 01ae2987e3
2 changed files with 181 additions and 99 deletions

View File

@@ -127,6 +127,52 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool {
}
}
fn strip_parent_startup_context_bundle_from_forked_rollout(items: &mut Vec<RolloutItem>) {
let Some(turn_context_idx) = items
.iter()
.position(|item| matches!(item, RolloutItem::TurnContext(_)))
else {
return;
};
let first_turn_context_is_before_parent_turn = items[..turn_context_idx].iter().all(|item| {
!matches!(
item,
RolloutItem::ResponseItem(response_item)
if crate::context_manager::is_user_turn_boundary(response_item)
)
});
if !first_turn_context_is_before_parent_turn {
return;
}
let mut context_start = turn_context_idx;
while context_start > 0 {
let is_startup_context_item = match &items[context_start - 1] {
RolloutItem::ResponseItem(ResponseItem::Message { role, .. })
if role == "developer" =>
{
true
}
RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. })
if role == "user"
&& crate::event_mapping::is_contextual_user_message_content(content) =>
{
true
}
_ => false,
};
if !is_startup_context_item {
break;
}
context_start -= 1;
}
if context_start < turn_context_idx {
items.drain(context_start..=turn_context_idx);
}
}
/// Control-plane handle for multi-agent operations.
/// `AgentControl` is held by each session (via `SessionServices`). It provides capability to
/// spawn new agents and the inter-agent communication layer.
@@ -396,6 +442,7 @@ impl AgentControl {
forked_rollout_items =
truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns);
}
strip_parent_startup_context_bundle_from_forked_rollout(&mut forked_rollout_items);
// MultiAgentV2 root/subagent usage hints are injected as standalone developer
// messages at thread start. When forking history, drop hints from the parent
// so the child gets a fresh hint that matches its own session source/config.
@@ -417,56 +464,19 @@ impl AgentControl {
} else {
Vec::new()
};
let developer_instruction_texts_to_filter = match parent_thread.as_ref() {
Some(parent_thread) => parent_thread
.codex
.session
.get_config()
.await
.developer_instructions
.clone(),
None => config.developer_instructions.clone(),
}
.into_iter()
.filter(|developer_instructions| !developer_instructions.is_empty())
.collect::<Vec<_>>();
// Parent developer instructions may be one fragment inside a larger startup context
// message. Strip only that fragment so parent context updates remain fork-visible.
forked_rollout_items = forked_rollout_items
.into_iter()
.filter_map(|mut item| {
if !keep_forked_rollout_item(&item) {
return None;
}
forked_rollout_items.retain(|item| {
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = item
&& role == "developer"
&& let [ContentItem::InputText { text }] = content.as_slice()
&& multi_agent_v2_usage_hint_texts_to_filter
.iter()
.any(|usage_hint_text| usage_hint_text == text)
{
return false;
}
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) =
&mut item
&& role == "developer"
{
if let [ContentItem::InputText { text }] = content.as_slice()
&& multi_agent_v2_usage_hint_texts_to_filter
.iter()
.any(|usage_hint_text| usage_hint_text == text)
{
return None;
}
content.retain(|content_item| {
let ContentItem::InputText { text } = content_item else {
return true;
};
!developer_instruction_texts_to_filter
.iter()
.any(|developer_instructions| developer_instructions == text)
});
if content.is_empty() {
return None;
}
}
Some(item)
})
.collect();
keep_forked_rollout_item(item)
});
state
.fork_thread_with_source(

View File

@@ -14,17 +14,22 @@ use codex_features::Feature;
use codex_login::CodexAuth;
use codex_protocol::AgentPath;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::ContentItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnContextItem;
use codex_protocol::protocol::TurnStartedEvent;
use codex_thread_store::ArchiveThreadParams;
use codex_thread_store::LocalThreadStore;
@@ -83,6 +88,89 @@ fn spawn_agent_call(call_id: &str) -> ResponseItem {
}
}
fn turn_context_item_for_test(turn_id: &str) -> TurnContextItem {
TurnContextItem {
turn_id: Some(turn_id.to_string()),
trace_id: None,
cwd: std::path::PathBuf::from("/tmp"),
current_date: None,
timezone: None,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::ReadOnly {
network_access: false,
},
permission_profile: None,
network: None,
file_system_sandbox_policy: None,
model: "gpt-test".to_string(),
personality: None,
collaboration_mode: None,
realtime_active: None,
effort: None,
summary: ReasoningSummary::Auto,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: None,
}
}
#[test]
fn strips_only_parent_startup_context_bundle() {
let parent_user_message = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "parent seed context".to_string(),
}],
phase: None,
};
let parent_later_context_update = ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "Parent later context update.".to_string(),
}],
phase: None,
};
let mut structural_rollout = vec![
RolloutItem::ResponseItem(ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "Parent startup context.".to_string(),
}],
phase: None,
}),
RolloutItem::ResponseItem(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>".to_string(),
}],
phase: None,
}),
RolloutItem::TurnContext(turn_context_item_for_test("startup")),
RolloutItem::ResponseItem(parent_user_message.clone()),
RolloutItem::ResponseItem(parent_later_context_update.clone()),
RolloutItem::TurnContext(turn_context_item_for_test("later")),
];
strip_parent_startup_context_bundle_from_forked_rollout(&mut structural_rollout);
assert_eq!(structural_rollout.len(), 3);
assert!(
matches!(&structural_rollout[0], RolloutItem::ResponseItem(item) if item == &parent_user_message)
);
assert!(
matches!(&structural_rollout[1], RolloutItem::ResponseItem(item) if item == &parent_later_context_update)
);
assert!(
matches!(&structural_rollout[2], RolloutItem::TurnContext(_)),
"later parent context updates should survive startup-bundle stripping"
);
}
struct AgentControlHarness {
_home: TempDir,
config: Config,
@@ -628,42 +716,6 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
.session
.record_context_updates_and_set_reference_context_item(turn_context.as_ref())
.await;
let mut expected_history = parent_thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
for item in &mut expected_history {
if let ResponseItem::Message { role, content, .. } = item
&& role == "developer"
{
content.retain(|content_item| {
!matches!(
content_item,
ContentItem::InputText { text }
if text == "Parent developer instructions."
)
});
}
}
expected_history.retain(|item| match item {
ResponseItem::Message { role, content, .. } if role == "developer" => {
!content.is_empty()
&& !matches!(
content.as_slice(),
[ContentItem::InputText { text }]
if text == "Parent root guidance."
|| text == "Parent subagent guidance."
)
}
_ => true,
});
assert!(
!expected_history.is_empty(),
"test setup should keep parent startup context blocks"
);
parent_thread
.inject_user_message_without_turn("parent seed context".to_string())
.await;
@@ -736,21 +788,42 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
.expect("child thread should be registered");
assert_ne!(child_thread_id, parent_thread_id);
let history = child_thread.codex.session.clone_history().await;
expected_history.extend([
let message_summary = |item: &ResponseItem| match item {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "parent seed context".to_string(),
}],
phase: None,
},
assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)),
]);
role,
content,
phase,
..
} => {
let text = content
.iter()
.map(|content_item| match content_item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
text.as_str()
}
_ => "",
})
.collect::<Vec<_>>()
.join("\n");
(role.clone(), text, phase.clone())
}
_ => panic!("expected only message items in forked child history"),
};
assert_eq!(
history.raw_items(),
expected_history.as_slice(),
"forked child history should keep parent context blocks while removing duplicated setup instructions"
history
.raw_items()
.iter()
.map(message_summary)
.collect::<Vec<_>>(),
vec![
("user".to_string(), "parent seed context".to_string(), None,),
(
"assistant".to_string(),
"parent final answer".to_string(),
Some(MessagePhase::FinalAnswer),
),
],
"forked child history should drop parent startup context while keeping parent conversation items"
);
let child_rollout_path = child_thread
.rollout_path()
@@ -765,7 +838,6 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
!child_rollout.contains("Parent root guidance."),
"forked child rollout should not retain parent multi-agent setup guidance"
);
let expected = (
child_thread_id,
Op::UserInput {