Skip to main content

codex_rollout/
model_context.rs

1use codex_protocol::items::TurnItem;
2use codex_protocol::models::ResponseItem;
3use codex_protocol::protocol::EventMsg;
4use codex_protocol::protocol::InterAgentCommunication;
5use codex_protocol::protocol::RolloutItem;
6use codex_protocol::protocol::SessionMetaLine;
7
8/// Whether a reverse model-context scan needs more rollout items.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum ModelContextScanProgress {
11    /// The reader should provide the next older rollout item.
12    Continue,
13    /// The scan has collected a safe bounded suffix.
14    Complete,
15}
16
17/// Accumulates newest-to-oldest rollout items until they are sufficient to reconstruct the latest
18/// model context.
19///
20/// Storage implementations own how they fetch older items. Local JSONL readers and future
21/// reverse-paged cloud readers can both feed their items through this scan to share the cutoff
22/// rules and chronological replay assembly.
23///
24/// The scan stops once it has both:
25///
26/// - `saw_compaction`: a `CompactedItem` with `replacement_history` and `window_number`;
27/// - `saw_completed_turn_context`: a completed user turn with a compatible `TurnContextItem`.
28///
29/// If the scan reaches the beginning before finding a bounded cutoff, it has already collected
30/// the complete replay and so we can return that directly.
31///
32/// `TurnContextItem` does not identify whether it came from a user turn, so one only counts after
33/// the same turn also proves a user-turn boundary: a paginated
34/// `ItemCompleted(UserMessage)` marker, agent message, or inter-agent message. Paginated writers
35/// persist that marker for real user turns; older rollouts without it conservatively scan to the
36/// beginning. A raw `role=user` response item is not sufficient because contextual user fragments
37/// use that role but do not count as turn boundaries during reconstruction. The compaction restores
38/// model-visible items; the turn context restores previous settings (`model`, `comp_hash`, and
39/// `realtime_active`) and the reference baseline.
40///
41/// These paginated shapes disable the bounded cutoff:
42///
43/// - compaction without `replacement_history` or `window_number`;
44/// - rollback markers;
45///
46/// When one appears, the scanner continues to the beginning and returns the complete replay.
47#[derive(Debug, Default)]
48pub struct ModelContextScan {
49    items_newest_first: Vec<RolloutItem>,
50    saw_compaction: bool,
51    saw_completed_turn_context: bool,
52    must_scan_to_start: bool,
53    active_segment: ActiveTurnSegment,
54}
55
56impl ModelContextScan {
57    /// Adds the next newest-to-oldest rollout item and reports whether the reader can stop.
58    pub fn push(&mut self, item: RolloutItem) -> ModelContextScanProgress {
59        let progress = self.observe(&item);
60        self.items_newest_first.push(item);
61        progress
62    }
63
64    /// Returns the collected items in chronological order with canonical head metadata.
65    ///
66    /// Call this after the reader reaches the beginning of its source or after [`Self::push`]
67    /// returns [`ModelContextScanProgress::Complete`].
68    pub fn finish(mut self, session_meta: SessionMetaLine) -> Vec<RolloutItem> {
69        self.items_newest_first.reverse();
70        if self.has_bounded_cutoff() {
71            // A bounded scan stops before reaching the head. Prepend the separately loaded head
72            // SessionMeta, which remains canonical when copied fork history contains later
73            // metadata.
74            self.items_newest_first
75                .insert(0, RolloutItem::SessionMeta(session_meta));
76        }
77        self.items_newest_first
78    }
79
80    fn observe(&mut self, item: &RolloutItem) -> ModelContextScanProgress {
81        if self.must_scan_to_start {
82            return ModelContextScanProgress::Continue;
83        }
84
85        match item {
86            RolloutItem::Compacted(compacted)
87                if compacted.replacement_history.is_none() || compacted.window_number.is_none() =>
88            {
89                self.must_scan_to_start = true;
90            }
91            RolloutItem::Compacted(_) => {
92                self.saw_compaction = true;
93            }
94            RolloutItem::EventMsg(EventMsg::ThreadRolledBack(_)) => {
95                // Paginated threads reject rollback. Keep old rollouts correct rather than
96                // duplicating rollback survival semantics in this bounded selector.
97                self.must_scan_to_start = true;
98            }
99            RolloutItem::EventMsg(EventMsg::ItemCompleted(event)) => {
100                if self.active_segment.turn_id.is_none() {
101                    self.active_segment.turn_id = Some(event.turn_id.clone());
102                }
103                if turn_ids_are_compatible(
104                    self.active_segment.turn_id.as_deref(),
105                    Some(event.turn_id.as_str()),
106                ) {
107                    self.active_segment.has_user_turn |=
108                        matches!(&event.item, TurnItem::UserMessage(_));
109                }
110            }
111            RolloutItem::EventMsg(EventMsg::TurnComplete(event)) => {
112                self.active_segment
113                    .turn_id
114                    .get_or_insert_with(|| event.turn_id.clone());
115            }
116            RolloutItem::EventMsg(EventMsg::TurnAborted(event)) => {
117                if let Some(turn_id) = &event.turn_id {
118                    self.active_segment
119                        .turn_id
120                        .get_or_insert_with(|| turn_id.clone());
121                }
122            }
123            RolloutItem::EventMsg(EventMsg::TurnStarted(event)) => {
124                if turn_ids_are_compatible(
125                    self.active_segment.turn_id.as_deref(),
126                    Some(event.turn_id.as_str()),
127                ) {
128                    self.finalize_active_segment();
129                }
130            }
131            RolloutItem::TurnContext(context) => {
132                if self.active_segment.turn_id.is_none() {
133                    self.active_segment.turn_id = context.turn_id.clone();
134                }
135                if turn_ids_are_compatible(
136                    self.active_segment.turn_id.as_deref(),
137                    context.turn_id.as_deref(),
138                ) {
139                    self.active_segment.has_turn_context = true;
140                }
141            }
142            RolloutItem::ResponseItem(response_item) => {
143                self.active_segment.has_user_turn |=
144                    response_item_counts_as_user_turn(response_item);
145            }
146            RolloutItem::InterAgentCommunication(_) => {
147                self.active_segment.has_user_turn = true;
148            }
149            RolloutItem::EventMsg(EventMsg::UserMessage(_)) => {
150                self.active_segment.has_user_turn = true;
151            }
152            RolloutItem::EventMsg(_)
153            | RolloutItem::SessionMeta(_)
154            | RolloutItem::InterAgentCommunicationMetadata { .. }
155            | RolloutItem::WorldState(_) => {}
156        }
157
158        if self.has_bounded_cutoff() {
159            ModelContextScanProgress::Complete
160        } else {
161            ModelContextScanProgress::Continue
162        }
163    }
164
165    fn finalize_active_segment(&mut self) {
166        if self.active_segment.has_user_turn && self.active_segment.has_turn_context {
167            self.saw_completed_turn_context = true;
168        }
169        self.active_segment = ActiveTurnSegment::default();
170    }
171
172    fn has_bounded_cutoff(&self) -> bool {
173        !self.must_scan_to_start && self.saw_compaction && self.saw_completed_turn_context
174    }
175}
176
177#[derive(Debug, Default)]
178struct ActiveTurnSegment {
179    turn_id: Option<String>,
180    has_user_turn: bool,
181    has_turn_context: bool,
182}
183
184fn turn_ids_are_compatible(active_turn_id: Option<&str>, item_turn_id: Option<&str>) -> bool {
185    active_turn_id
186        .is_none_or(|turn_id| item_turn_id.is_none_or(|item_turn_id| item_turn_id == turn_id))
187}
188
189fn response_item_counts_as_user_turn(response_item: &ResponseItem) -> bool {
190    match response_item {
191        ResponseItem::AgentMessage { .. } => true,
192        ResponseItem::Message { role, content, .. } => {
193            role == "assistant" && InterAgentCommunication::is_message_content(content)
194        }
195        _ => false,
196    }
197}