feat: parent config add

This commit is contained in:
jif-oai
2026-05-18 17:19:39 +02:00
parent 4ca60ef9ff
commit 4a25ca68fb
2 changed files with 347 additions and 48 deletions

View File

@@ -5,6 +5,7 @@ use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::resolve_role_config;
use crate::agent::status::is_final;
use crate::codex_thread::ThreadConfigSnapshot;
use crate::event_mapping::is_contextual_user_message_content;
use crate::session::emit_subagent_session_started;
use crate::session_prefix::format_subagent_context_line;
use crate::session_prefix::format_subagent_notification_message;
@@ -96,34 +97,53 @@ fn agent_nickname_candidates(
.collect()
}
fn keep_forked_rollout_item(item: &RolloutItem) -> bool {
fn keep_forked_response_item(item: &ResponseItem) -> bool {
match item {
RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str()
{
"system" | "developer" | "user" => true,
ResponseItem::Message {
role,
content,
phase,
..
} => match role.as_str() {
"system" => true,
"developer" => false,
"user" => !is_contextual_user_message_content(content),
"assistant" => *phase == Some(MessagePhase::FinalAnswer),
_ => false,
},
RolloutItem::ResponseItem(
ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::CompactionTrigger
| ResponseItem::ContextCompaction { .. }
| ResponseItem::Other,
) => false,
ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::CompactionTrigger
| ResponseItem::ContextCompaction { .. }
| ResponseItem::Other => false,
}
}
fn sanitize_forked_rollout_item(item: &mut RolloutItem) -> bool {
match item {
RolloutItem::ResponseItem(response_item) => keep_forked_response_item(response_item),
// A forked child gets its own runtime config, including spawned-agent
// instructions, so it must establish a fresh context diff baseline.
RolloutItem::TurnContext(_) => false,
RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true,
RolloutItem::Compacted(compacted) => {
if compacted.replacement_history.is_none() {
compacted.message =
"Previous compacted history omitted for forked agent.".to_string();
}
let replacement_history = compacted.replacement_history.get_or_insert_with(Vec::new);
replacement_history.retain(keep_forked_response_item);
true
}
RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true,
}
}
@@ -417,9 +437,9 @@ impl AgentControl {
} else {
Vec::new()
};
forked_rollout_items.retain(|item| {
forked_rollout_items.retain_mut(|item| {
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = item
&& role == "developer"
&& role.as_str() == "developer"
&& let [ContentItem::InputText { text }] = content.as_slice()
&& multi_agent_v2_usage_hint_texts_to_filter
.iter()
@@ -428,7 +448,7 @@ impl AgentControl {
return false;
}
keep_forked_rollout_item(item)
sanitize_forked_rollout_item(item)
});
state

View File

@@ -17,6 +17,7 @@ use codex_protocol::config_types::ModeKind;
use codex_protocol::models::ContentItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InterAgentCommunication;
@@ -62,6 +63,28 @@ fn text_input(text: &str) -> Op {
.into()
}
fn user_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
phase: None,
}
}
fn developer_message(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
phase: None,
}
}
fn assistant_message(text: &str, phase: Option<MessagePhase>) -> ResponseItem {
ResponseItem::Message {
id: None,
@@ -83,6 +106,61 @@ fn spawn_agent_call(call_id: &str) -> ResponseItem {
}
}
#[test]
fn sanitize_forked_rollout_item_filters_compaction_replacement_history() {
let contextual_user_message = <SubagentNotification as ContextualUserFragment>::into(
SubagentNotification::new("/root/worker", AgentStatus::Running),
);
let mut compacted_item = RolloutItem::Compacted(CompactedItem {
message: "summary".to_string(),
replacement_history: Some(vec![
developer_message("parent setup"),
contextual_user_message,
user_message("real parent prompt"),
assistant_message("draft response", /*phase*/ None),
assistant_message("final response", Some(MessagePhase::FinalAnswer)),
]),
});
assert!(sanitize_forked_rollout_item(&mut compacted_item));
let RolloutItem::Compacted(CompactedItem {
replacement_history,
..
}) = compacted_item
else {
panic!("expected compacted rollout item");
};
assert_eq!(
replacement_history,
Some(vec![
user_message("real parent prompt"),
assistant_message("final response", Some(MessagePhase::FinalAnswer)),
])
);
}
#[test]
fn sanitize_forked_rollout_item_turns_legacy_compaction_into_empty_checkpoint() {
let mut compacted_item = RolloutItem::Compacted(CompactedItem {
message: "summary with parent setup".to_string(),
replacement_history: None,
});
assert!(sanitize_forked_rollout_item(&mut compacted_item));
let RolloutItem::Compacted(CompactedItem {
message,
replacement_history,
}) = compacted_item
else {
panic!("expected compacted rollout item");
};
assert_eq!(
message,
"Previous compacted history omitted for forked agent."
);
assert_eq!(replacement_history, Some(Vec::new()));
}
struct AgentControlHarness {
_home: TempDir,
config: Config,
@@ -639,22 +717,13 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
.record_conversation_items(
turn_context.as_ref(),
&[
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "Parent root guidance.".to_string(),
}],
phase: None,
},
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "Parent subagent guidance.".to_string(),
}],
phase: None,
},
developer_message("Parent root guidance."),
developer_message("Parent subagent guidance."),
developer_message("Parent setup context."),
<SubagentNotification as ContextualUserFragment>::into(SubagentNotification::new(
"/root/worker",
AgentStatus::Running,
)),
assistant_message("parent commentary", Some(MessagePhase::Commentary)),
assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)),
assistant_message("parent unknown phase", /*phase*/ None),
@@ -711,14 +780,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
assert_ne!(child_thread_id, parent_thread_id);
let history = child_thread.codex.session.clone_history().await;
let expected_history = [
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "parent seed context".to_string(),
}],
phase: None,
},
user_message("parent seed context"),
assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)),
];
assert_eq!(
@@ -757,6 +819,223 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
.expect("parent shutdown should submit");
}
#[tokio::test]
async fn spawn_agent_fork_sanitizes_modern_compaction_replacement_history_and_suffix() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_thread().await;
parent_thread
.inject_user_message_without_turn("ignored pre-compaction prompt".to_string())
.await;
parent_thread
.codex
.session
.persist_rollout_items(&[RolloutItem::Compacted(CompactedItem {
message: "summary".to_string(),
replacement_history: Some(vec![
developer_message("compacted parent setup"),
<SubagentNotification as ContextualUserFragment>::into(SubagentNotification::new(
"/root/worker",
AgentStatus::Running,
)),
user_message("compacted real prompt"),
assistant_message("compacted draft", /*phase*/ None),
assistant_message("compacted final", Some(MessagePhase::FinalAnswer)),
]),
})])
.await;
parent_thread
.inject_user_message_without_turn("suffix prompt".to_string())
.await;
let turn_context = parent_thread.codex.session.new_default_turn().await;
let parent_spawn_call_id = "spawn-call-modern-compaction".to_string();
parent_thread
.codex
.session
.record_conversation_items(
turn_context.as_ref(),
&[
developer_message("suffix parent setup"),
<SubagentNotification as ContextualUserFragment>::into(SubagentNotification::new(
"/root/worker",
AgentStatus::Running,
)),
assistant_message("suffix final", Some(MessagePhase::FinalAnswer)),
spawn_agent_call(&parent_spawn_call_id),
],
)
.await;
parent_thread
.codex
.session
.ensure_rollout_materialized()
.await;
parent_thread
.codex
.session
.flush_rollout()
.await
.expect("parent rollout should flush");
let child_thread_id = harness
.control
.spawn_agent_with_metadata(
harness.config.clone(),
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
..Default::default()
},
)
.await
.expect("forked spawn should succeed")
.thread_id;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
let history = child_thread.codex.session.clone_history().await;
assert_eq!(
history.raw_items(),
&[
user_message("compacted real prompt"),
assistant_message("compacted final", Some(MessagePhase::FinalAnswer)),
user_message("suffix prompt"),
assistant_message("suffix final", Some(MessagePhase::FinalAnswer)),
],
"forked child history should use sanitized replacement history plus sanitized suffix"
);
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
#[tokio::test]
async fn spawn_agent_fork_legacy_compaction_cuts_off_unsafe_prefix_without_summary() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, parent_thread) = harness.start_thread().await;
parent_thread
.inject_user_message_without_turn("pre-compaction prompt".to_string())
.await;
parent_thread
.codex
.session
.persist_rollout_items(&[RolloutItem::Compacted(CompactedItem {
message: "summary with parent setup".to_string(),
replacement_history: None,
})])
.await;
parent_thread
.inject_user_message_without_turn("post-compaction prompt".to_string())
.await;
let turn_context = parent_thread.codex.session.new_default_turn().await;
let parent_spawn_call_id = "spawn-call-legacy-compaction".to_string();
parent_thread
.codex
.session
.record_conversation_items(
turn_context.as_ref(),
&[
developer_message("post-compaction parent setup"),
<SubagentNotification as ContextualUserFragment>::into(SubagentNotification::new(
"/root/worker",
AgentStatus::Running,
)),
assistant_message("post-compaction final", Some(MessagePhase::FinalAnswer)),
spawn_agent_call(&parent_spawn_call_id),
],
)
.await;
parent_thread
.codex
.session
.ensure_rollout_materialized()
.await;
parent_thread
.codex
.session
.flush_rollout()
.await
.expect("parent rollout should flush");
let child_thread_id = harness
.control
.spawn_agent_with_metadata(
harness.config.clone(),
text_input("child task"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
..Default::default()
},
)
.await
.expect("forked spawn should succeed")
.thread_id;
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
let history = child_thread.codex.session.clone_history().await;
assert!(
!history_contains_text(history.raw_items(), "pre-compaction prompt"),
"legacy compaction should cut off the unsafe pre-compaction prefix"
);
assert!(
!history_contains_text(history.raw_items(), "summary with parent setup"),
"legacy compaction summary should not become child model-visible history"
);
assert!(
!history_contains_text(history.raw_items(), "post-compaction parent setup"),
"parent developer setup after compaction should be dropped"
);
assert!(
history_contains_text(history.raw_items(), "post-compaction prompt"),
"real user transcript after legacy compaction should survive"
);
assert!(
history_contains_text(history.raw_items(), "post-compaction final"),
"assistant final answer after legacy compaction should survive"
);
let _ = harness
.control
.shutdown_live_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let _ = parent_thread
.submit(Op::Shutdown {})
.await
.expect("parent shutdown should submit");
}
#[tokio::test]
async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
let harness = AgentControlHarness::new().await;