Skip to main content

af_agent_runtime/
history.rs

1use af_agent_session::{ContentBlock, DeliveryMode, Event, SessionEvent, SessionProjection};
2use af_context::RunId;
3use af_llm::{ChatMessage, InputImage};
4
5use crate::RuntimeError;
6
7/// Validated execution facts and the model-visible tail of one durable Run.
8#[derive(Debug, Clone)]
9pub struct RunHistory {
10    pub(crate) run_id: RunId,
11    pub(crate) projection: SessionProjection,
12    pub(crate) events: Vec<SessionEvent>,
13    pub(crate) transcript: Vec<ChatMessage>,
14}
15
16/// Incremental two-pass replay. First fold every fact; then read model events
17/// only after the latest successful summary. No persisted checkpoint is created.
18pub struct RunHistoryReplay {
19    run_id: RunId,
20    projection: SessionProjection,
21    events: Vec<SessionEvent>,
22    capturing_run: bool,
23    latest_images: Vec<InputImage>,
24    pending_summary: Option<(u64, String, Vec<InputImage>)>,
25    summary: Option<(u64, String, Vec<InputImage>)>,
26    model_seq: Option<u64>,
27    transcript: Vec<ChatMessage>,
28}
29
30impl RunHistoryReplay {
31    /// Start reducing a Run from the first Session event.
32    pub fn new(run_id: RunId) -> Self {
33        Self {
34            run_id,
35            projection: Default::default(),
36            events: Vec::new(),
37            capturing_run: false,
38            latest_images: Vec::new(),
39            pending_summary: None,
40            summary: None,
41            model_seq: None,
42            transcript: Vec::new(),
43        }
44    }
45
46    /// Validate and fold one bounded page of complete durable facts in order.
47    pub fn facts(&mut self, events: &[SessionEvent]) -> Result<(), RuntimeError> {
48        if self.model_seq.is_some() {
49            return Err(RuntimeError::Invariant(
50                "facts after model replay began".into(),
51            ));
52        }
53        for event in events {
54            let open = self.projection.open_compaction.clone();
55            let active = self.projection.active_run_id.clone();
56            self.projection
57                .apply_facts(event)
58                .map_err(|error| RuntimeError::Invariant(error.to_string()))?;
59            match &event.event {
60                Event::InputQueued {
61                    run_id,
62                    mode: DeliveryMode::Followup,
63                    ..
64                }
65                | Event::RunStarted { run_id, .. }
66                    if run_id == &self.run_id =>
67                {
68                    self.capturing_run = true
69                }
70                Event::UserMessage { content, .. } => {
71                    self.latest_images.clear();
72                    for block in content {
73                        if let ContentBlock::Resource {
74                            resource_id,
75                            media_type,
76                        } = block
77                        {
78                            if media_type.starts_with("image/") {
79                                let image = InputImage {
80                                    asset_id: resource_id.parse().map_err(|_| {
81                                        RuntimeError::InvalidInput(
82                                            "invalid stored image identity".into(),
83                                        )
84                                    })?,
85                                    media_type: media_type.clone(),
86                                };
87                                image.validate().map_err(|error| {
88                                    RuntimeError::InvalidInput(error.to_string())
89                                })?;
90                                self.latest_images.push(image);
91                            }
92                        }
93                    }
94                }
95                Event::SummaryReplaced {
96                    run_id,
97                    through_seq,
98                    summary,
99                    ..
100                } if active.as_ref() == Some(run_id)
101                    && open.as_ref().is_some_and(|(_, seq)| seq == through_seq)
102                    && *through_seq < event.seq =>
103                {
104                    self.pending_summary =
105                        Some((event.seq, summary.clone(), self.latest_images.clone()));
106                }
107                Event::CompactionFinished { status, error, .. } => {
108                    if status == "completed" && error.is_none() {
109                        if let Some(summary) = self.pending_summary.take() {
110                            self.summary = Some(summary);
111                        }
112                    } else {
113                        self.pending_summary = None;
114                    }
115                }
116                Event::CompactionStarted { .. } => self.pending_summary = None,
117                _ => {}
118            }
119            if self.capturing_run {
120                self.events.push(event.clone());
121            }
122        }
123        Ok(())
124    }
125
126    /// Freeze the first pass and return the exclusive cursor for the model tail.
127    pub fn begin_model_tail(&mut self) -> u64 {
128        if let Some(seq) = self.model_seq {
129            return seq;
130        }
131        let seq = if let Some((seq, summary, images)) = &self.summary {
132            if !images.is_empty() {
133                let mut message = ChatMessage::user("");
134                message.images = images.clone();
135                self.transcript.push(message);
136            }
137            crate::replay::replace_with_summary(&mut self.transcript, summary);
138            *seq
139        } else {
140            0
141        };
142        self.model_seq = Some(seq);
143        seq
144    }
145
146    /// Fold subsequent model events through the exact first-pass upper boundary.
147    pub fn model_tail(&mut self, events: &[SessionEvent]) -> Result<(), RuntimeError> {
148        let mut seq = self
149            .model_seq
150            .ok_or_else(|| RuntimeError::Invariant("model tail was not initialized".into()))?;
151        for event in events {
152            if event.seq != seq + 1
153                || event.seq > self.projection.last_seq
154                || self.projection.session_id.as_ref() != Some(&event.session_id)
155            {
156                return Err(RuntimeError::Invariant(
157                    "model tail boundary changed".into(),
158                ));
159            }
160            // Only the successful summary selected during fact validation is used.
161            if !matches!(event.event, Event::SummaryReplaced { .. }) {
162                crate::replay::apply_transcript_event(&mut self.transcript, event)?;
163            }
164            seq = event.seq;
165        }
166        self.model_seq = Some(seq);
167        Ok(())
168    }
169
170    /// Finish only when the model pass reaches the validated facts' upper boundary.
171    pub fn finish(self) -> Result<RunHistory, RuntimeError> {
172        if self.model_seq != Some(self.projection.last_seq) {
173            return Err(RuntimeError::Invariant("incomplete model tail".into()));
174        }
175        Ok(RunHistory {
176            run_id: self.run_id,
177            projection: self.projection,
178            events: self.events,
179            transcript: self.transcript,
180        })
181    }
182}
183
184impl RunHistory {
185    /// Last fact sequence covered by both replay passes.
186    pub fn last_seq(&self) -> u64 {
187        self.projection.last_seq
188    }
189
190    /// Convenience for in-memory callers; stores should feed bounded pages to RunHistoryReplay.
191    pub fn from_events(run_id: RunId, events: &[SessionEvent]) -> Result<Self, RuntimeError> {
192        let mut replay = RunHistoryReplay::new(run_id);
193        replay.facts(events)?;
194        let after = replay.begin_model_tail();
195        replay.model_tail(&events[events.partition_point(|event| event.seq <= after)..])?;
196        replay.finish()
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use af_agent_session::{text, RunStatus};
204
205    fn history(completed: bool) -> Vec<SessionEvent> {
206        let run_id: RunId = "old-run".parse().unwrap();
207        let events = vec![
208            Event::SessionCreated {
209                profile_revision_id: "profile".parse().unwrap(),
210            },
211            Event::InputQueued {
212                input_id: "old-input".parse().unwrap(),
213                run_id: run_id.clone(),
214                mode: DeliveryMode::Followup,
215                content: text("old"),
216                explicit_skill: None,
217            },
218            Event::InputClaimed {
219                input_id: "old-input".parse().unwrap(),
220                run_id: run_id.clone(),
221            },
222            Event::RunStarted {
223                input_id: "old-input".parse().unwrap(),
224                run_id: run_id.clone(),
225            },
226            Event::TurnStarted {
227                run_id: run_id.clone(),
228                turn: 1,
229            },
230            Event::StepStarted {
231                run_id: run_id.clone(),
232                step: 1,
233            },
234            Event::UserMessage {
235                run_id: run_id.clone(),
236                content: vec![
237                    ContentBlock::Text {
238                        text: "old large transcript".repeat(1000),
239                    },
240                    ContentBlock::Resource {
241                        resource_id: "image".into(),
242                        media_type: "image/png".into(),
243                    },
244                ],
245            },
246            Event::UsageRecorded {
247                metering: None,
248                run_id: run_id.clone(),
249                operation_id: "attempt".into(),
250                prompt_tokens: 5,
251                completion_tokens: 3,
252                cost_units: 0,
253            },
254            Event::CompactionStarted {
255                run_id: run_id.clone(),
256                compaction_id: "compact".into(),
257                source_through_seq: 8,
258            },
259            Event::SummaryReplaced {
260                run_id: run_id.clone(),
261                through_seq: 8,
262                summary: "confirmed summary".into(),
263                compactor: "test".into(),
264                model: "test".into(),
265            },
266            Event::CompactionFinished {
267                run_id: run_id.clone(),
268                compaction_id: "compact".into(),
269                status: if completed { "completed" } else { "failed" }.into(),
270                error: (!completed).then(|| "failed".into()),
271            },
272            Event::StepFinished {
273                run_id: run_id.clone(),
274                step: 1,
275            },
276            Event::TurnFinished {
277                run_id: run_id.clone(),
278                turn: 1,
279            },
280            Event::RunFinished {
281                run_id,
282                status: RunStatus::Completed,
283                error_code: None,
284            },
285            Event::InputQueued {
286                input_id: "input".parse().unwrap(),
287                run_id: "new-run".parse().unwrap(),
288                mode: DeliveryMode::Followup,
289                content: text("new"),
290                explicit_skill: None,
291            },
292        ];
293        events
294            .into_iter()
295            .enumerate()
296            .map(|(index, event)| SessionEvent {
297                session_id: "session".parse().unwrap(),
298                seq: index as u64 + 1,
299                occurred_at: chrono::Utc::now(),
300                event,
301            })
302            .collect()
303    }
304
305    #[test]
306    fn successful_summary_starts_model_tail_without_losing_images_or_usage() {
307        let events = history(true);
308        let mut replay = RunHistoryReplay::new("new-run".parse().unwrap());
309        for page in events.chunks(3) {
310            replay.facts(page).unwrap();
311        }
312        assert_eq!(replay.begin_model_tail(), 10);
313        for page in events[10..].chunks(2) {
314            replay.model_tail(page).unwrap();
315        }
316        let resumed = replay.finish().unwrap();
317        assert_eq!(resumed.projection.usage_for("old-run"), (5, 3));
318        assert_eq!(resumed.events.len(), 1);
319        assert_eq!(resumed.transcript.len(), 2);
320        assert_eq!(
321            resumed.transcript[0].content.as_deref(),
322            Some("Conversation summary:\nconfirmed summary")
323        );
324        assert_eq!(resumed.transcript[1].images[0].asset_id, "image");
325        assert!(resumed.projection.messages.is_empty());
326    }
327
328    #[test]
329    fn failed_or_unfinished_summaries_never_hide_original_messages() {
330        for events in [history(false), history(true)[..10].to_vec()] {
331            let mut replay = RunHistoryReplay::new("new-run".parse().unwrap());
332            replay.facts(&events).unwrap();
333            assert_eq!(replay.begin_model_tail(), 0);
334            replay.model_tail(&events).unwrap();
335            let resumed = replay.finish().unwrap();
336            assert!(resumed.transcript[0]
337                .content
338                .as_deref()
339                .unwrap()
340                .contains("old large transcript"));
341            assert_eq!(resumed.projection.usage_for("old-run"), (5, 3));
342        }
343    }
344
345    #[test]
346    fn a_model_tail_cannot_skip_events_or_cross_the_validated_boundary() {
347        let events = history(true);
348        let mut replay = RunHistoryReplay::new("new-run".parse().unwrap());
349        replay.facts(&events).unwrap();
350        assert!(replay.model_tail(&events).is_err());
351        replay.begin_model_tail();
352        assert!(replay.model_tail(&events[11..]).is_err());
353        assert!(replay.facts(&[]).is_err());
354        assert!(replay.finish().is_err());
355    }
356}