phi_core/context/orchestration.rs
1use super::compaction::*;
2use super::config::*;
3use super::strategy::*;
4use super::token::{resolve_counter, TokenCounter};
5use crate::session::Session;
6use crate::types::*;
7use std::sync::Arc;
8
9// ---------------------------------------------------------------------------
10// Compaction orchestration — cross-loop block creation
11// ---------------------------------------------------------------------------
12
13/// Resolve `CompactionScope` to a concrete number of earlier loops to include.
14///
15/// For `FixedCount(n)`, returns `n` directly.
16/// For `TokenBudget`, walks the chain backward from the current loop,
17/// accumulating token estimates per loop, and stops
18/// when `max_context_tokens` would be exceeded.
19///
20/// Note: with `TokenBudget`, the scope can include loops whose raw messages
21/// exceed the token budget. This is intentional — the compacted summaries
22/// will fit in the window even when the originals don't, enabling richer
23/// context for expensive summarisation strategies.
24fn resolve_scope(
25 session: &Session,
26 chain: &[String],
27 scope: &CompactionScope,
28 max_context_tokens: usize,
29 counter: &dyn TokenCounter,
30) -> usize {
31 match scope {
32 CompactionScope::FixedCount(n) => *n,
33 CompactionScope::TokenBudget => {
34 let mut budget = max_context_tokens;
35 let mut count = 0usize;
36 // Walk backward from the loop before current (chain.last() is current)
37 for loop_id in chain.iter().rev().skip(1) {
38 if let Some(record) = session.get_loop(loop_id) {
39 let loop_tokens = counter.estimate_messages(&record.messages);
40 if loop_tokens > budget {
41 break;
42 }
43 budget -= loop_tokens;
44 count += 1;
45 }
46 }
47 count
48 }
49 }
50}
51
52/// Create `CompactionBlock`s for the current loop and earlier loops within scope.
53/// Mutates the session in place.
54///
55/// When `counter` is `None`, uses `HeuristicTokenCounter` (chars/4) as the default.
56/// The caller is responsible for persisting the session to disk afterward.
57///
58/// **0.9.0 breaking change**: this function is now `async fn` so it can drive
59/// the async `BlockCompactionStrategy::compact` method. Callers must `.await`
60/// the call; synchronous callers can use `tokio::runtime::Handle::current()
61/// .block_on(...)` if no awaiter is available (uncommon — session compaction
62/// is typically invoked from within an agent loop, which is already async).
63pub async fn compact_session_loops(
64 session: &mut Session,
65 current_loop_id: &str,
66 strategy: &dyn BlockCompactionStrategy,
67 config: &CompactionConfig,
68 max_context_tokens: usize,
69 counter: Option<&Arc<dyn TokenCounter>>,
70) {
71 let counter = resolve_counter(counter);
72 let chain = session.loop_chain_to(current_loop_id);
73
74 // 1. Compact current loop (most recent — all three sections)
75 //
76 // We compute the block first (so the &mut borrow is released before the
77 // .await suspension point) to keep the borrow checker happy across the
78 // async boundary.
79 let current_block = if let Some(current) = session.get_loop(current_loop_id) {
80 Some(strategy.compact(current, config, true).await)
81 } else {
82 None
83 };
84 if let Some(block) = current_block {
85 if let Some(current) = session.get_loop_mut(current_loop_id) {
86 current.compaction_block = Some(block);
87 }
88 }
89
90 // 2. Resolve scope, then compact earlier loops on the chain (only keep_compacted)
91 let earlier_count = resolve_scope(
92 session,
93 &chain,
94 &config.compaction_scope,
95 max_context_tokens,
96 counter,
97 )
98 .min(chain.len().saturating_sub(1));
99 let earlier_start = chain.len().saturating_sub(1 + earlier_count);
100 let earlier_ids: Vec<String> = chain[earlier_start..chain.len().saturating_sub(1)].to_vec();
101 for loop_id in earlier_ids {
102 // Compute block first (immutable borrow) so the .await can run, then
103 // re-borrow mutably to assign.
104 let needs_block = session
105 .get_loop(&loop_id)
106 .map(|r| r.compaction_block.is_none())
107 .unwrap_or(false);
108 if !needs_block {
109 continue;
110 }
111 let block_opt = if let Some(record) = session.get_loop(&loop_id) {
112 Some(strategy.compact(record, config, false).await)
113 } else {
114 None
115 };
116 if let Some(block) = block_opt {
117 if let Some(record) = session.get_loop_mut(&loop_id) {
118 record.compaction_block = Some(block);
119 }
120 }
121 }
122}
123
124// ---------------------------------------------------------------------------
125// Context builder — loads from CompactionBlocks when available
126// ---------------------------------------------------------------------------
127
128/// Build a compacted context by walking the loop chain and loading from
129/// `CompactionBlock`s where available, raw messages otherwise.
130///
131/// For the most recent loop: loads keep_first + keep_compacted + keep_recent.
132/// For older loops: loads only keep_compacted.
133/// Loops outside the resolved scope are skipped entirely.
134///
135/// When `counter` is `None`, uses `HeuristicTokenCounter` (chars/4) as the default.
136pub fn build_context_from_session(
137 session: &Session,
138 current_loop_id: &str,
139 config: &CompactionConfig,
140 max_context_tokens: usize,
141 counter: Option<&Arc<dyn TokenCounter>>,
142) -> Vec<AgentMessage> {
143 let counter = resolve_counter(counter);
144 let chain = session.loop_chain_to(current_loop_id);
145 let mut context = Vec::new();
146
147 let earlier_count = resolve_scope(
148 session,
149 &chain,
150 &config.compaction_scope,
151 max_context_tokens,
152 counter,
153 );
154 let load_start = chain.len().saturating_sub(earlier_count + 1);
155
156 for (i, loop_id) in chain.iter().enumerate().skip(load_start) {
157 let Some(record) = session.get_loop(loop_id) else {
158 continue;
159 };
160 let is_most_recent = i == chain.len() - 1;
161
162 match &record.compaction_block {
163 Some(block) => {
164 if is_most_recent {
165 // Load keep_first (original messages for that range)
166 if let Some(ref range) = block.keep_first {
167 let turn_map = TurnMap::from_messages(&record.messages);
168 let msgs = turn_map.messages_for_range(range, &record.messages);
169 context.extend_from_slice(msgs);
170 }
171 // Load keep_compacted (summarised middle)
172 if let Some(ref section) = block.keep_compacted {
173 context.extend(section.messages.iter().cloned());
174 }
175 // Load keep_recent (truncated tool outputs)
176 if let Some(ref section) = block.keep_recent {
177 context.extend(section.messages.iter().cloned());
178 }
179 } else {
180 // Older loops: only load keep_compacted
181 if let Some(ref section) = block.keep_compacted {
182 context.extend(section.messages.iter().cloned());
183 }
184 }
185 }
186 None => {
187 // No compaction block — load raw messages
188 context.extend(record.messages.iter().cloned());
189 }
190 }
191 }
192
193 context
194}