mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
## What changed - Limit multi-agent V2 `fork_turns` guidance to `all` and `none`. Accept legacy positive integer strings as full-history forks instead of truncating parent history. - Remove partial-history fork handling and telemetry, and direct model override usage to `fork_turns: "none"`. - Strip inherited `multi_agent.role_instructions` and `multi_agent.usage_hint` content by kind, including persisted hints whose wording predates the current bundled instructions. ## Testing Update regression coverage for legacy `"1"` and `"3"` values inheriting full history, invalid and zero values being rejected, and stale parent hints being removed from compacted history with both marked and unmarked instructions. GitOrigin-RevId: 8f6017dc66ad8deefd7745399ef76e1bb1435bb2
198 lines
7.1 KiB
Rust
198 lines
7.1 KiB
Rust
//! Helpers for truncating rollouts based on "user turn" boundaries.
|
|
//!
|
|
//! In core, "user turns" are detected by scanning `ResponseItem::Message` items and
|
|
//! interpreting them via `event_mapping::parse_turn_item(...)`.
|
|
|
|
use crate::context_manager::is_user_turn_boundary;
|
|
use crate::event_mapping;
|
|
use codex_app_server_protocol::TurnStatus;
|
|
use codex_app_server_protocol::build_turns_from_rollout_items;
|
|
use codex_history::InitialHistory;
|
|
use codex_history::RolloutItem;
|
|
use codex_protocol::error::CodexErr;
|
|
use codex_protocol::error::Result as CodexResult;
|
|
use codex_protocol::items::TurnItem;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::protocol::EventMsg;
|
|
|
|
pub(crate) fn initial_history_has_prior_user_turns(conversation_history: &InitialHistory) -> bool {
|
|
conversation_history.scan_rollout_items(rollout_item_is_user_turn_boundary)
|
|
}
|
|
|
|
fn rollout_item_is_user_turn_boundary(item: &RolloutItem) -> bool {
|
|
match item {
|
|
RolloutItem::ResponseItem(item) => is_user_turn_boundary(item),
|
|
RolloutItem::Compacted(checkpoint) => checkpoint
|
|
.replacement_history
|
|
.iter()
|
|
.flatten()
|
|
.any(|item| is_user_turn_boundary(item)),
|
|
RolloutItem::InterAgentCommunication(_) => true,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Return the indices of user message boundaries in a rollout.
|
|
///
|
|
/// A user message boundary is a `RolloutItem::ResponseItem(ResponseItem::Message { .. })`
|
|
/// whose parsed turn item is `TurnItem::UserMessage`.
|
|
///
|
|
/// Rollouts can contain `ThreadRolledBack` markers. Those markers indicate that the
|
|
/// last N user turns were removed from the effective thread history; we apply them here so
|
|
/// indexing uses the post-rollback history rather than the raw stream.
|
|
pub(crate) fn user_message_positions_in_rollout(items: &[RolloutItem]) -> Vec<usize> {
|
|
let mut user_positions = Vec::new();
|
|
for (idx, item) in items.iter().enumerate() {
|
|
match item {
|
|
RolloutItem::ResponseItem(item)
|
|
if matches!(&item.item, ResponseItem::Message { .. })
|
|
&& matches!(
|
|
event_mapping::parse_turn_item(&item.item),
|
|
Some(TurnItem::UserMessage(_))
|
|
) =>
|
|
{
|
|
user_positions.push(idx);
|
|
}
|
|
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(rollback)) => {
|
|
let num_turns = usize::try_from(rollback.num_turns).unwrap_or(usize::MAX);
|
|
let new_len = user_positions.len().saturating_sub(num_turns);
|
|
user_positions.truncate(new_len);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
user_positions
|
|
}
|
|
|
|
/// Return a prefix of `items` obtained by cutting strictly before the nth user message.
|
|
///
|
|
/// The boundary index is 0-based from the start of `items` (so `n_from_start = 0` returns
|
|
/// a prefix that excludes the first user message and everything after it).
|
|
///
|
|
/// If `n_from_start` is `usize::MAX`, this returns the full rollout (no truncation).
|
|
/// If fewer than or equal to `n_from_start` user messages exist, this returns the full
|
|
/// rollout unchanged.
|
|
pub(crate) fn truncate_rollout_before_nth_user_message_from_start(
|
|
mut items: Vec<RolloutItem>,
|
|
n_from_start: usize,
|
|
) -> Vec<RolloutItem> {
|
|
if n_from_start == usize::MAX {
|
|
return items;
|
|
}
|
|
|
|
let user_positions = user_message_positions_in_rollout(&items);
|
|
|
|
// If fewer than or equal to n user messages exist, keep the full rollout.
|
|
if user_positions.len() <= n_from_start {
|
|
return items;
|
|
}
|
|
|
|
// Cut strictly before the nth user message (do not keep the nth itself).
|
|
let cut_idx = user_positions[n_from_start];
|
|
items.truncate(cut_idx);
|
|
items
|
|
}
|
|
|
|
/// Return a rollout prefix ending after the requested persisted terminal turn.
|
|
///
|
|
/// The turn must still be present in the effective post-rollback history and
|
|
/// must have an explicit persisted TurnStarted boundary. Synthetic IDs
|
|
/// generated while projecting legacy rollouts are intentionally unsupported
|
|
/// because they do not provide a stable raw rollout boundary for a fork.
|
|
pub fn truncate_rollout_after_turn_id(
|
|
mut items: Vec<RolloutItem>,
|
|
last_turn_id: &str,
|
|
) -> CodexResult<Vec<RolloutItem>> {
|
|
let turns = build_turns_from_rollout_items(&items);
|
|
let turn = turns
|
|
.iter()
|
|
.find(|turn| turn.id == last_turn_id)
|
|
.ok_or_else(|| {
|
|
CodexErr::InvalidRequest(format!(
|
|
"lastTurnId '{last_turn_id}' was not found in the source thread"
|
|
))
|
|
})?;
|
|
|
|
let target_start_index = items
|
|
.iter()
|
|
.position(|item| {
|
|
matches!(
|
|
item,
|
|
RolloutItem::EventMsg(EventMsg::TurnStarted(event))
|
|
if event.turn_id == last_turn_id
|
|
)
|
|
})
|
|
.ok_or_else(|| {
|
|
CodexErr::InvalidRequest(format!(
|
|
"lastTurnId '{last_turn_id}' is not a persisted canonical turn in the source thread"
|
|
))
|
|
})?;
|
|
|
|
if matches!(turn.status, TurnStatus::InProgress) {
|
|
return Err(CodexErr::InvalidRequest(format!(
|
|
"lastTurnId '{last_turn_id}' identifies an in-progress turn"
|
|
)));
|
|
}
|
|
|
|
let cut_index = items
|
|
.iter()
|
|
.enumerate()
|
|
.skip(target_start_index.saturating_add(1))
|
|
.find_map(|(index, item)| {
|
|
matches!(item, RolloutItem::EventMsg(EventMsg::TurnStarted(_))).then_some(index)
|
|
})
|
|
.unwrap_or(items.len());
|
|
items.truncate(cut_index);
|
|
Ok(items)
|
|
}
|
|
|
|
/// Return a rollout prefix ending immediately before the requested persisted turn.
|
|
pub fn truncate_rollout_before_turn_id(
|
|
mut items: Vec<RolloutItem>,
|
|
before_turn_id: &str,
|
|
) -> CodexResult<Vec<RolloutItem>> {
|
|
let cut_index = items.iter().position(|item| {
|
|
matches!(
|
|
item,
|
|
RolloutItem::EventMsg(EventMsg::TurnStarted(event))
|
|
if event.turn_id == before_turn_id
|
|
)
|
|
});
|
|
|
|
let Some(cut_index) = cut_index else {
|
|
// Older rollouts can expose generated turn IDs without a TurnStarted item to fork at.
|
|
if build_turns_from_rollout_items(&items)
|
|
.iter()
|
|
.any(|turn| turn.id == before_turn_id)
|
|
{
|
|
return Err(CodexErr::InvalidRequest(format!(
|
|
"beforeTurnId '{before_turn_id}' is not a persisted canonical turn in the source thread"
|
|
)));
|
|
}
|
|
|
|
return Err(CodexErr::InvalidRequest(format!(
|
|
"beforeTurnId '{before_turn_id}' was not found in the source thread"
|
|
)));
|
|
};
|
|
|
|
// A persisted turn boundary proves the turn exists unless a later rollback removes it.
|
|
if items[cut_index + 1..]
|
|
.iter()
|
|
.any(|item| matches!(item, RolloutItem::EventMsg(EventMsg::ThreadRolledBack(_))))
|
|
&& !build_turns_from_rollout_items(&items)
|
|
.iter()
|
|
.any(|turn| turn.id == before_turn_id)
|
|
{
|
|
return Err(CodexErr::InvalidRequest(format!(
|
|
"beforeTurnId '{before_turn_id}' was not found in the source thread"
|
|
)));
|
|
}
|
|
|
|
items.truncate(cut_index);
|
|
Ok(items)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "thread_rollout_truncation_tests.rs"]
|
|
mod tests;
|