Skip to content

Preserve context baselines for full-history agent forks#23352

Merged
jif-oai merged 2 commits into
mainfrom
jif/simplify-2
May 19, 2026
Merged

Preserve context baselines for full-history agent forks#23352
jif-oai merged 2 commits into
mainfrom
jif/simplify-2

Conversation

@jif-oai
Copy link
Copy Markdown
Collaborator

@jif-oai jif-oai commented May 18, 2026

Why

Full-history agent forks should continue from the same prompt prefix as the parent. Dropping the stored TurnContext baseline forced the child to rebuild startup context on its first turn, which can duplicate developer instructions and also loses the cache continuity that a full-history fork is supposed to preserve.

Truncated forks are different: once we keep only the last N turns, the original prompt prefix is no longer intact, so the child must establish a fresh context baseline.

What changed

  • Preserve RolloutItem::TurnContext when forking with SpawnAgentForkMode::FullHistory, and keep dropping it for truncated forks:
    fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: bool) -> 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,
    // Full-history forks preserve the cached prompt prefix and can keep diffing
    // from the parent's durable baseline. Truncated forks drop part of that prompt,
    // so they must rebuild context on their first child turn.
    RolloutItem::TurnContext(_) => preserve_reference_context_item,
    RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true,
    and
    let preserve_reference_context_item = matches!(fork_mode, SpawnAgentForkMode::FullHistory);
    forked_rollout_items
    .retain(|item| keep_forked_rollout_item(item, preserve_reference_context_item));
  • Remove the special-case MultiAgentV2 usage-hint filtering path. Full-history fork now preserves the cached developer prefix instead of trying to reconstruct part of it.
  • Extend the fork coverage to assert both sides of the contract: full-history forks keep the parent reference baseline, while last-N forks rebuild context after truncation:
    async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
    let harness = AgentControlHarness::new().await;
    let mut parent_config = harness.config.clone();
    let _ = parent_config.features.enable(Feature::MultiAgentV2);
    parent_config.multi_agent_v2.root_agent_usage_hint_text =
    Some("Parent root guidance.".to_string());
    parent_config.multi_agent_v2.subagent_usage_hint_text =
    Some("Parent subagent guidance.".to_string());
    let mut child_config = harness.config.clone();
    let _ = child_config.features.enable(Feature::MultiAgentV2);
    child_config.multi_agent_v2.root_agent_usage_hint_text =
    Some("Child root guidance.".to_string());
    child_config.multi_agent_v2.subagent_usage_hint_text =
    Some("Child subagent guidance.".to_string());
    let new_thread = harness
    .manager
    .start_thread(parent_config.clone())
    .await
    .expect("start parent thread");
    let parent_thread_id = new_thread.thread_id;
    let parent_thread = new_thread.thread;
    parent_thread
    .inject_user_message_without_turn("parent seed context".to_string())
    .await;
    let turn_context = parent_thread.codex.session.new_default_turn().await;
    let parent_spawn_call_id = "spawn-call-history".to_string();
    let trigger_message = InterAgentCommunication::new(
    AgentPath::root(),
    AgentPath::try_from("/root/worker").expect("agent path"),
    Vec::new(),
    "parent trigger message".to_string(),
    /*trigger_turn*/ true,
    );
    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),
    ],
    )
    .await;
    let parent_reference_context_item = turn_context.to_turn_context_item();
    parent_thread
    .codex
    .session
    .persist_rollout_items(&[RolloutItem::TurnContext(
    parent_reference_context_item.clone(),
    )])
    .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(
    child_config,
    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");
    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,
    },
    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 final answer", Some(MessagePhase::FinalAnswer)),
    ];
    assert_eq!(
    history.raw_items(),
    &expected_history,
    "full-history forked child history should preserve the cached developer prefix while filtering non-final assistant/tool chatter"
    );
    assert_eq!(
    serde_json::to_value(child_thread.codex.session.reference_context_item().await)
    .expect("serialize child reference context item"),
    serde_json::to_value(Some(parent_reference_context_item))
    .expect("serialize expected reference context item"),
    "full-history forked child should preserve the parent diff baseline"
    );
    and
    async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
    let harness = AgentControlHarness::new().await;
    let (parent_thread_id, parent_thread) = harness.start_thread().await;
    parent_thread
    .inject_user_message_without_turn("old parent context".to_string())
    .await;
    let queued_communication = InterAgentCommunication::new(
    AgentPath::root(),
    AgentPath::try_from("/root/worker").expect("agent path"),
    Vec::new(),
    "queued message".to_string(),
    /*trigger_turn*/ false,
    );
    let queued_turn_context = parent_thread.codex.session.new_default_turn().await;
    parent_thread
    .codex
    .session
    .record_conversation_items(
    queued_turn_context.as_ref(),
    &[queued_communication.to_response_input_item().into()],
    )
    .await;
    let triggered_communication = InterAgentCommunication::new(
    AgentPath::root(),
    AgentPath::try_from("/root/worker").expect("agent path"),
    Vec::new(),
    "triggered context".to_string(),
    /*trigger_turn*/ true,
    );
    let triggered_turn_context = parent_thread.codex.session.new_default_turn().await;
    parent_thread
    .codex
    .session
    .record_conversation_items(
    triggered_turn_context.as_ref(),
    &[triggered_communication.to_response_input_item().into()],
    )
    .await;
    parent_thread
    .inject_user_message_without_turn("current parent task".to_string())
    .await;
    let spawn_turn_context = parent_thread.codex.session.new_default_turn().await;
    let parent_spawn_call_id = "spawn-call-last-n".to_string();
    parent_thread
    .codex
    .session
    .record_conversation_items(
    spawn_turn_context.as_ref(),
    &[spawn_agent_call(&parent_spawn_call_id)],
    )
    .await;
    parent_thread
    .codex
    .session
    .persist_rollout_items(&[RolloutItem::TurnContext(
    spawn_turn_context.to_turn_context_item(),
    )])
    .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::LastNTurns(2)),
    ..Default::default()
    },
    )
    .await
    .expect("forked spawn should keep only the last two turns")
    .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(), "old parent context"),
    "forked child history should drop parent context outside the requested last-N turn window"
    );
    assert!(
    !history_contains_text(history.raw_items(), "queued message"),
    "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"
    );
    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!(
    child_thread
    .codex
    .session
    .reference_context_item()
    .await
    .is_none(),

Verification

  • cargo test -p codex-core spawn_agent_can_fork_parent_thread_history_with_sanitized_items -- --nocapture
  • RUST_MIN_STACK=16777216 cargo test -p codex-core spawn_agent_fork_last_n_turns_keeps_only_recent_turns -- --nocapture

@jif-oai jif-oai requested a review from a team as a code owner May 18, 2026 19:53
@jif-oai jif-oai changed the title jif/fix Preserve context baselines for full-history agent forks May 18, 2026
Copy link
Copy Markdown
Contributor

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4090717d94

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread codex-rs/core/src/agent/control.rs
@jif-oai jif-oai merged commit 826b218 into main May 19, 2026
31 checks passed
@jif-oai jif-oai deleted the jif/simplify-2 branch May 19, 2026 08:34
@github-actions github-actions Bot locked and limited conversation to collaborators May 19, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants