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}