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.
57pub fn compact_session_loops(
58    session: &mut Session,
59    current_loop_id: &str,
60    strategy: &dyn BlockCompactionStrategy,
61    config: &CompactionConfig,
62    max_context_tokens: usize,
63    counter: Option<&Arc<dyn TokenCounter>>,
64) {
65    let counter = resolve_counter(counter);
66    let chain = session.loop_chain_to(current_loop_id);
67
68    // 1. Compact current loop (most recent — all three sections)
69    if let Some(current) = session.get_loop_mut(current_loop_id) {
70        current.compaction_block = Some(strategy.compact(current, config, true));
71    }
72
73    // 2. Resolve scope, then compact earlier loops on the chain (only keep_compacted)
74    let earlier_count = resolve_scope(
75        session,
76        &chain,
77        &config.compaction_scope,
78        max_context_tokens,
79        counter,
80    )
81    .min(chain.len().saturating_sub(1));
82    let earlier_start = chain.len().saturating_sub(1 + earlier_count);
83    for loop_id in &chain[earlier_start..chain.len().saturating_sub(1)] {
84        if let Some(record) = session.get_loop_mut(loop_id) {
85            if record.compaction_block.is_none() {
86                record.compaction_block = Some(strategy.compact(record, config, false));
87            }
88        }
89    }
90}
91
92// ---------------------------------------------------------------------------
93// Context builder — loads from CompactionBlocks when available
94// ---------------------------------------------------------------------------
95
96/// Build a compacted context by walking the loop chain and loading from
97/// `CompactionBlock`s where available, raw messages otherwise.
98///
99/// For the most recent loop: loads keep_first + keep_compacted + keep_recent.
100/// For older loops: loads only keep_compacted.
101/// Loops outside the resolved scope are skipped entirely.
102///
103/// When `counter` is `None`, uses `HeuristicTokenCounter` (chars/4) as the default.
104pub fn build_context_from_session(
105    session: &Session,
106    current_loop_id: &str,
107    config: &CompactionConfig,
108    max_context_tokens: usize,
109    counter: Option<&Arc<dyn TokenCounter>>,
110) -> Vec<AgentMessage> {
111    let counter = resolve_counter(counter);
112    let chain = session.loop_chain_to(current_loop_id);
113    let mut context = Vec::new();
114
115    let earlier_count = resolve_scope(
116        session,
117        &chain,
118        &config.compaction_scope,
119        max_context_tokens,
120        counter,
121    );
122    let load_start = chain.len().saturating_sub(earlier_count + 1);
123
124    for (i, loop_id) in chain.iter().enumerate().skip(load_start) {
125        let Some(record) = session.get_loop(loop_id) else {
126            continue;
127        };
128        let is_most_recent = i == chain.len() - 1;
129
130        match &record.compaction_block {
131            Some(block) => {
132                if is_most_recent {
133                    // Load keep_first (original messages for that range)
134                    if let Some(ref range) = block.keep_first {
135                        let turn_map = TurnMap::from_messages(&record.messages);
136                        let msgs = turn_map.messages_for_range(range, &record.messages);
137                        context.extend_from_slice(msgs);
138                    }
139                    // Load keep_compacted (summarised middle)
140                    if let Some(ref section) = block.keep_compacted {
141                        context.extend(section.messages.iter().cloned());
142                    }
143                    // Load keep_recent (truncated tool outputs)
144                    if let Some(ref section) = block.keep_recent {
145                        context.extend(section.messages.iter().cloned());
146                    }
147                } else {
148                    // Older loops: only load keep_compacted
149                    if let Some(ref section) = block.keep_compacted {
150                        context.extend(section.messages.iter().cloned());
151                    }
152                }
153            }
154            None => {
155                // No compaction block — load raw messages
156                context.extend(record.messages.iter().cloned());
157            }
158        }
159    }
160
161    context
162}