Skip to main content

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}