Skip to main content

phi_core/context/
compaction.rs

1use crate::types::*;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4
5// ---------------------------------------------------------------------------
6// Compaction Block — non-destructive overlay on LoopRecord
7// ---------------------------------------------------------------------------
8
9/// Non-destructive compaction overlay. Stored on `LoopRecord` alongside
10/// the original messages. When present, the context loader uses this block
11/// instead of raw messages.
12///
13/// Three sections control what gets loaded into context:
14/// - `keep_first`: turns kept verbatim from the start (most recent loop only)
15/// - `keep_recent`: recent turns with truncated tool outputs (most recent loop only)
16/// - `keep_compacted`: fully summarised section (all loops)
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct CompactionBlock {
19    /// Turns kept verbatim from the start of the loop.
20    /// Only populated for the MOST RECENT loop. For older loops this is `None`.
21    /// During context load: original messages in this range are used as-is.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub keep_first: Option<TurnRange>,
24
25    /// Recent turns with tool outputs truncated. Rest unchanged.
26    /// Only populated for the MOST RECENT loop. For older loops this is `None`.
27    /// Invariant: if a ToolCall is in range, its corresponding ToolResult is too.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub keep_recent: Option<CompactedSection>,
30
31    /// Fully summarised middle section (most recent loop) or entire loop (older loops).
32    /// Relevant for ALL loops — this is what gets loaded from earlier loops.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub keep_compacted: Option<CompactedSection>,
35
36    /// When this block was created.
37    #[serde(rename = "createdAt")]
38    pub created_at: DateTime<Utc>,
39}
40
41/// A range of turns within a loop, identified by turn indices.
42/// Both bounds are inclusive. These correspond to `TurnId.turn_index` values.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44pub struct TurnRange {
45    #[serde(rename = "startTurn")]
46    pub start_turn: u32,
47    #[serde(rename = "endTurn")]
48    pub end_turn: u32,
49}
50
51/// A range of turns plus the compacted replacement messages for that range.
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct CompactedSection {
54    /// The turn range this section replaces.
55    pub range: TurnRange,
56    /// Replacement messages loaded into context instead of the originals.
57    pub messages: Vec<AgentMessage>,
58}
59
60// ---------------------------------------------------------------------------
61// TurnMap — maps turn indices to message index ranges
62// ---------------------------------------------------------------------------
63
64/// Maps turn indices to message index ranges within a message array.
65/// Built from messages by grouping on `TurnId.turn_index`.
66pub struct TurnMap {
67    /// Indexed by position (0-based). Each entry is `(start_msg_idx, end_msg_idx)` inclusive.
68    entries: Vec<(usize, usize)>,
69}
70
71impl TurnMap {
72    /// Build from messages by grouping on `turn_id.turn_index`.
73    /// Messages without a `turn_id` are treated as their own single-message group.
74    pub fn from_messages(messages: &[AgentMessage]) -> Self {
75        let mut entries: Vec<(usize, usize)> = Vec::new();
76        let mut current_turn: Option<u32> = None;
77
78        for (i, msg) in messages.iter().enumerate() {
79            let turn_idx = msg.turn_id().map(|t| t.turn_index);
80            match (turn_idx, current_turn) {
81                (Some(idx), Some(cur)) if idx == cur => {
82                    // Same turn — extend end index
83                    if let Some(last) = entries.last_mut() {
84                        last.1 = i;
85                    }
86                }
87                (Some(idx), _) => {
88                    // New turn
89                    entries.push((i, i));
90                    current_turn = Some(idx);
91                }
92                (None, _) => {
93                    // Legacy message without turn_id — treat as its own group
94                    entries.push((i, i));
95                    current_turn = None;
96                }
97            }
98        }
99
100        Self { entries }
101    }
102
103    /// Number of turn groups.
104    pub fn turn_count(&self) -> u32 {
105        self.entries.len() as u32
106    }
107
108    /// Slice of messages belonging to a `TurnRange`.
109    pub fn messages_for_range<'a>(
110        &self,
111        range: &TurnRange,
112        all_msgs: &'a [AgentMessage],
113    ) -> &'a [AgentMessage] {
114        if range.start_turn as usize >= self.entries.len()
115            || range.end_turn as usize >= self.entries.len()
116        {
117            return &[];
118        }
119        let start = self.entries[range.start_turn as usize].0;
120        let end = self.entries[range.end_turn as usize].1;
121        &all_msgs[start..=end]
122    }
123
124    /// Message index range `(start, end)` inclusive for a single turn.
125    pub fn turn_msg_range(&self, turn_index: u32) -> Option<(usize, usize)> {
126        self.entries.get(turn_index as usize).copied()
127    }
128}