Compare commits

...

3 Commits

Author SHA1 Message Date
jif-oai
d1ca6f64c1 Drop parent spawn call from forked history 2026-05-18 18:54:15 +02:00
jif-oai
d845500096 smaller fix 2026-05-18 17:55:34 +02:00
jif-oai
4a25ca68fb feat: parent config add 2026-05-18 17:19:39 +02:00
4 changed files with 94 additions and 166 deletions

View File

@@ -18,8 +18,6 @@ use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::ContentItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::InterAgentCommunication;
@@ -96,37 +94,6 @@ fn agent_nickname_candidates(
.collect()
}
fn keep_forked_rollout_item(item: &RolloutItem) -> bool {
match item {
RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str()
{
"system" | "developer" | "user" => true,
"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,
// 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,
}
}
/// 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.
@@ -349,11 +316,11 @@ impl AgentControl {
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
) -> CodexResult<crate::thread_manager::NewThread> {
if options.fork_parent_spawn_call_id.is_none() {
let Some(parent_spawn_call_id) = options.fork_parent_spawn_call_id.as_deref() else {
return Err(CodexErr::Fatal(
"spawn_agent fork requires a parent spawn call id".to_string(),
));
}
};
let Some(fork_mode) = options.fork_mode.as_ref() else {
return Err(CodexErr::Fatal(
"spawn_agent fork requires a fork mode".to_string(),
@@ -396,39 +363,12 @@ impl AgentControl {
forked_rollout_items =
truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns);
}
// 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.
let multi_agent_v2_usage_hint_texts_to_filter: Vec<String> =
if let Some(parent_thread) = parent_thread.as_ref() {
parent_thread
.codex
.session
.configured_multi_agent_v2_usage_hint_texts()
.await
} else if config.features.enabled(Feature::MultiAgentV2) {
[
config.multi_agent_v2.root_agent_usage_hint_text.clone(),
config.multi_agent_v2.subagent_usage_hint_text.clone(),
]
.into_iter()
.flatten()
.collect()
} else {
Vec::new()
};
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;
}
keep_forked_rollout_item(item)
!matches!(
item,
RolloutItem::ResponseItem(ResponseItem::FunctionCall { name, call_id, .. })
if name == "spawn_agent" && call_id == parent_spawn_call_id
)
});
state

View File

@@ -20,6 +20,7 @@ use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TurnAbortReason;
@@ -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,
@@ -154,6 +177,15 @@ fn history_contains_text(history_items: &[ResponseItem], needle: &str) -> bool {
})
}
fn history_contains_function_call(history_items: &[ResponseItem], call_id: &str) -> bool {
history_items.iter().any(|item| {
matches!(
item,
ResponseItem::FunctionCall { call_id: item_call_id, .. } if item_call_id == call_id
)
})
}
fn history_contains_assistant_inter_agent_communication(
history_items: &[ResponseItem],
expected: &InterAgentCommunication,
@@ -600,7 +632,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() {
}
#[tokio::test]
async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
async fn spawn_agent_can_fork_parent_thread_history_without_replaying_parent_spawn_call() {
let harness = AgentControlHarness::new().await;
let mut parent_config = harness.config.clone();
let _ = parent_config.features.enable(Feature::MultiAgentV2);
@@ -625,6 +657,12 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
.inject_user_message_without_turn("parent seed context".to_string())
.await;
let turn_context = parent_thread.codex.session.new_default_turn().await;
let parent_turn_context_item = turn_context.to_turn_context_item();
parent_thread
.codex
.session
.persist_rollout_items(&[RolloutItem::TurnContext(parent_turn_context_item.clone())])
.await;
let parent_spawn_call_id = "spawn-call-history".to_string();
let trigger_message = InterAgentCommunication::new(
AgentPath::root(),
@@ -633,41 +671,31 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
"parent trigger message".to_string(),
/*trigger_turn*/ true,
);
let trigger_item = trigger_message.to_response_input_item().into();
let parent_items = vec![
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),
ResponseItem::Reasoning {
id: String::new(),
summary: Vec::new(),
content: None,
encrypted_content: None,
},
trigger_item,
spawn_agent_call(&parent_spawn_call_id),
];
parent_thread
.codex
.session
.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,
},
assistant_message("parent commentary", Some(MessagePhase::Commentary)),
assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)),
assistant_message("parent unknown phase", /*phase*/ None),
ResponseItem::Reasoning {
id: "parent-reasoning".to_string(),
summary: Vec::new(),
content: None,
encrypted_content: None,
},
trigger_message.to_response_input_item().into(),
spawn_agent_call(&parent_spawn_call_id),
],
)
.record_conversation_items(turn_context.as_ref(), &parent_items)
.await;
parent_thread
.codex
@@ -710,21 +738,24 @@ 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;
let expected_history = [
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)),
];
let mut expected_history = vec![user_message("parent seed context")];
let mut expected_parent_items = parent_items;
assert_eq!(
expected_parent_items.pop(),
Some(spawn_agent_call(&parent_spawn_call_id))
);
expected_history.extend(expected_parent_items);
assert_eq!(
history.raw_items(),
&expected_history,
"forked child history should keep only parent user messages and assistant final answers"
expected_history.as_slice(),
"forked child history should replay the parent rollout except for the trigger spawn call"
);
assert_eq!(
serde_json::to_value(child_thread.codex.session.reference_context_item().await)
.expect("serialize forked child reference context item"),
serde_json::to_value(Some(parent_turn_context_item))
.expect("serialize parent reference context item"),
"forked child should keep the parent context baseline instead of reinjecting startup context"
);
let expected = (
@@ -807,6 +838,10 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
history_contains_text(history.raw_items(), "unflushed final answer"),
"forked child history should include unflushed assistant final answers after flushing the parent rollout"
);
assert!(
!history_contains_function_call(history.raw_items(), &parent_spawn_call_id),
"forked child history should drop the trigger spawn call after flushing the parent rollout"
);
let _ = harness
.control
@@ -923,13 +958,17 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
"forked child history should drop queued inter-agent messages outside the requested last-N turn window"
);
assert!(
!history_contains_text(history.raw_items(), "triggered context"),
"forked child history should filter assistant inter-agent messages even when they fall inside the requested last-N turn window"
history_contains_text(history.raw_items(), "triggered context"),
"forked child history should preserve rollout items inside the requested last-N turn window"
);
assert!(
history_contains_text(history.raw_items(), "current parent task"),
"forked child history should keep the parent user message from the requested last-N turn window"
);
assert!(
!history_contains_function_call(history.raw_items(), &parent_spawn_call_id),
"forked child history should drop the trigger spawn call from the requested last-N turn window"
);
let _ = harness
.control

View File

@@ -888,22 +888,6 @@ impl Session {
}
}
pub(crate) async fn configured_multi_agent_v2_usage_hint_texts(&self) -> Vec<String> {
if !self.features.enabled(Feature::MultiAgentV2) {
return Vec::new();
}
let state = self.state.lock().await;
let config = &state.session_configuration.original_config_do_not_use;
[
config.multi_agent_v2.root_agent_usage_hint_text.clone(),
config.multi_agent_v2.subagent_usage_hint_text.clone(),
]
.into_iter()
.flatten()
.collect()
}
fn managed_network_proxy_active_for_permission_profile(
permission_profile: &PermissionProfile,
) -> bool {

View File

@@ -22,7 +22,6 @@ use codex_config::loader::project_trust_key;
use codex_config::types::ToolSuggestDisabledTool;
use codex_features::Feature;
use codex_features::Features;
use codex_login::CodexAuth;
use codex_model_provider_info::ModelProviderInfo;
use codex_models_manager::bundled_models_response;
@@ -6864,40 +6863,6 @@ async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_feature_dis
);
}
#[tokio::test]
async fn configured_multi_agent_v2_usage_hint_texts_use_effective_enabled_feature_state() {
let (mut session, _turn_context) =
make_multi_agent_v2_usage_hint_test_session(/*enable_multi_agent_v2*/ false).await;
let mut effective_features = Features::with_defaults();
effective_features.enable(Feature::MultiAgentV2);
Arc::get_mut(&mut session)
.expect("session should not be shared")
.features = effective_features.into();
let hint_texts = session.configured_multi_agent_v2_usage_hint_texts().await;
assert_eq!(
hint_texts,
vec![
"Root guidance.".to_string(),
"Subagent guidance.".to_string()
]
);
}
#[tokio::test]
async fn configured_multi_agent_v2_usage_hint_texts_omit_effectively_disabled_feature() {
let (mut session, _turn_context) =
make_multi_agent_v2_usage_hint_test_session(/*enable_multi_agent_v2*/ true).await;
Arc::get_mut(&mut session)
.expect("session should not be shared")
.features = Features::with_defaults().into();
let hint_texts = session.configured_multi_agent_v2_usage_hint_texts().await;
assert_eq!(hint_texts, Vec::<String>::new());
}
#[tokio::test]
async fn build_initial_context_omits_default_image_save_location_with_image_history() {
let (session, turn_context) = make_session_and_context().await;