Skip to main content

scv_core/
context.rs

1//! Choosing the history that fits the model's context window.
2//!
3//! The runtime keeps the whole canonical history; before every model request
4//! a [`ContextPolicy`] picks what the model sees. [`BudgetContextPolicy`], the
5//! default, keeps the newest whole turns that fit a token budget and replaces
6//! everything older with one summary note.
7
8use std::collections::VecDeque;
9
10use thiserror::Error;
11
12use crate::{
13    Message, ToolSpec,
14    history::{char_tail, truncate_chars},
15};
16
17/// The token budget of [`BudgetContextPolicy`]. Tokens are estimated from
18/// serialized bytes, not counted by a tokenizer.
19#[derive(Debug, Clone)]
20pub struct ContextConfig {
21    /// The model's context window.
22    pub max_tokens: usize,
23    /// Room kept free for the model's answer.
24    pub reserve_output_tokens: usize,
25    /// Extra room for estimation error.
26    pub safety_margin_tokens: usize,
27    /// Bytes of serialized text counted as one token.
28    pub bytes_per_token: usize,
29    /// Longest summary note that stands in for compacted history.
30    pub summary_max_chars: usize,
31}
32
33impl Default for ContextConfig {
34    fn default() -> Self {
35        Self {
36            max_tokens: 128_000,
37            reserve_output_tokens: 8_192,
38            safety_margin_tokens: 2_048,
39            bytes_per_token: 3,
40            summary_max_chars: 6_000,
41        }
42    }
43}
44
45/// The messages chosen for one model request, and what compaction cost.
46#[derive(Debug, Clone)]
47pub struct ContextSelection {
48    pub messages: Vec<Message>,
49    /// Estimated tokens of the full request before compaction.
50    pub before_tokens: usize,
51    /// Estimated tokens of the request as sent.
52    pub after_tokens: usize,
53    /// History messages left out (replaced by a summary note).
54    pub removed_messages: usize,
55}
56
57/// Why no selection fits: the budget is too small even for the newest turn,
58/// or for the system prompt and tool schemas alone.
59#[derive(Debug, Error)]
60#[error("{0}")]
61pub struct ContextError(pub String);
62
63/// Decides which history messages go into each model request.
64pub trait ContextPolicy: Send + Sync {
65    /// Choose the messages for a request with this system prompt and these
66    /// tools. The newest message is the user's current input and must be kept.
67    fn select(
68        &self,
69        history: &[Message],
70        system_prompt: &str,
71        tools: &[ToolSpec],
72    ) -> Result<ContextSelection, ContextError>;
73}
74
75/// Keeps the newest whole turns that fit [`ContextConfig`]'s budget and
76/// replaces older history with one bounded summary note.
77///
78/// A turn group is a user message and everything after it up to the next
79/// user message, so an assistant tool call is never separated from its
80/// result.
81pub struct BudgetContextPolicy {
82    config: ContextConfig,
83}
84
85impl BudgetContextPolicy {
86    /// A policy for `config`, which must leave room for history.
87    pub fn new(config: ContextConfig) -> Result<Self, ContextError> {
88        if config.bytes_per_token == 0 {
89            return Err(ContextError(
90                "context.bytes_per_token must be positive".into(),
91            ));
92        }
93        if config
94            .reserve_output_tokens
95            .saturating_add(config.safety_margin_tokens)
96            >= config.max_tokens
97        {
98            return Err(ContextError(
99                "context reserve and safety margin consume the model window".into(),
100            ));
101        }
102        Ok(Self { config })
103    }
104
105    fn string_tokens(&self, value: &str) -> usize {
106        value.len().div_ceil(self.config.bytes_per_token)
107    }
108
109    /// Estimated tokens of `messages`.
110    fn cost(&self, messages: &[Message]) -> usize {
111        messages
112            .iter()
113            .map(|message| message.estimated_tokens(self.config.bytes_per_token))
114            .sum()
115    }
116
117    /// Split `history` into turn groups: each starts at a user message (or at
118    /// the first message) and runs to the next user message.
119    fn group_messages(history: &[Message]) -> Vec<&[Message]> {
120        let mut groups = Vec::new();
121        let mut start = 0;
122        for (index, message) in history.iter().enumerate() {
123            if index > start && matches!(message, Message::User { .. }) {
124                groups.push(&history[start..index]);
125                start = index;
126            }
127        }
128        if start < history.len() {
129            groups.push(&history[start..]);
130        }
131        groups
132    }
133
134    fn summarize(&self, messages: &[Message]) -> String {
135        let mut output = format!(
136            "[SCV compacted {} earlier messages. Bounded extracts follow.]\n",
137            messages.len()
138        );
139        for message in messages {
140            let (label, content) = match message {
141                Message::User { content, .. } => ("user", content.as_str()),
142                Message::Assistant { content, .. } => ("assistant", content.as_str()),
143                Message::Tool {
144                    name,
145                    content,
146                    is_error,
147                    ..
148                } => {
149                    let status = if *is_error { "failed" } else { "ok" };
150                    output.push_str(&format!("tool {name} ({status}): "));
151                    ("", content.as_str())
152                }
153                Message::HistoryNote { content } => ("earlier", content.as_str()),
154            };
155            if !label.is_empty() {
156                output.push_str(label);
157                output.push_str(": ");
158            }
159            let tail = char_tail(content, 240);
160            output.push_str(&tail.replace('\n', " "));
161            output.push('\n');
162            if output.chars().count() >= self.config.summary_max_chars {
163                break;
164            }
165        }
166        truncate_chars(&output, self.config.summary_max_chars)
167    }
168}
169
170impl ContextPolicy for BudgetContextPolicy {
171    /// The budget for history is the window minus the system prompt, the tool
172    /// schemas, the output reserve, and the safety margin. Selection then:
173    ///
174    /// 1. keeps the newest turn group, failing if it alone is over budget;
175    /// 2. walks older groups from newest to oldest, keeping each while it
176    ///    fits, and stops at the first that does not (so the kept history is
177    ///    always one contiguous suffix);
178    /// 3. if anything was left out, puts one summary note of the left-out
179    ///    prefix in front. When the note does not fit beside the kept groups,
180    ///    the oldest kept group joins the summarized prefix and the note is
181    ///    rebuilt; with only the newest group left, the note is cut to the
182    ///    room that remains.
183    fn select(
184        &self,
185        history: &[Message],
186        system_prompt: &str,
187        tools: &[ToolSpec],
188    ) -> Result<ContextSelection, ContextError> {
189        if history.is_empty() {
190            return Ok(ContextSelection {
191                messages: Vec::new(),
192                before_tokens: 0,
193                after_tokens: 0,
194                removed_messages: 0,
195            });
196        }
197        let tools_bytes = serde_json::to_vec(tools).map_or(0, |value| value.len());
198        let static_tokens = self
199            .string_tokens(system_prompt)
200            .saturating_add(tools_bytes.div_ceil(self.config.bytes_per_token))
201            .saturating_add(self.config.reserve_output_tokens)
202            .saturating_add(self.config.safety_margin_tokens);
203        if static_tokens >= self.config.max_tokens {
204            return Err(ContextError(
205                "system prompt and tool schemas exceed context budget".into(),
206            ));
207        }
208        let budget = self.config.max_tokens - static_tokens;
209        let before_tokens = static_tokens.saturating_add(self.cost(history));
210
211        let groups = Self::group_messages(history);
212        let (newest, older) = groups
213            .split_last()
214            .expect("a non-empty history has at least one group");
215        let mut selected_cost = self.cost(newest);
216        if selected_cost > budget {
217            return Err(ContextError("newest turn exceeds context budget".into()));
218        }
219        let mut selected: VecDeque<&[Message]> = VecDeque::from([*newest]);
220        for group in older.iter().rev() {
221            let cost = self.cost(group);
222            if selected_cost.saturating_add(cost) > budget {
223                break;
224            }
225            selected.push_front(group);
226            selected_cost += cost;
227        }
228
229        let kept_messages: usize = selected.iter().map(|group| group.len()).sum();
230        let mut removed_messages = history.len() - kept_messages;
231        let selection = |note: Option<Message>,
232                         selected: VecDeque<&[Message]>,
233                         cost: usize,
234                         removed_messages: usize| ContextSelection {
235            messages: note
236                .into_iter()
237                .chain(selected.into_iter().flatten().cloned())
238                .collect(),
239            before_tokens,
240            after_tokens: static_tokens.saturating_add(cost),
241            removed_messages,
242        };
243        if removed_messages == 0 {
244            return Ok(selection(None, selected, selected_cost, 0));
245        }
246        loop {
247            let summary = self.summarize(&history[..removed_messages]);
248            let note = Message::HistoryNote {
249                content: summary.clone(),
250            };
251            let note_cost = note.estimated_tokens(self.config.bytes_per_token);
252            if selected_cost.saturating_add(note_cost) <= budget {
253                return Ok(selection(
254                    Some(note),
255                    selected,
256                    selected_cost + note_cost,
257                    removed_messages,
258                ));
259            }
260            if selected.len() == 1 {
261                let available_tokens = budget.saturating_sub(selected_cost);
262                let note =
263                    fit_history_note(&summary, available_tokens, self.config.bytes_per_token)
264                        .ok_or_else(|| {
265                            ContextError("compaction note cannot fit context budget".into())
266                        })?;
267                let note_cost = note.estimated_tokens(self.config.bytes_per_token);
268                return Ok(selection(
269                    Some(note),
270                    selected,
271                    selected_cost + note_cost,
272                    removed_messages,
273                ));
274            }
275            let dropped = selected
276                .pop_front()
277                .expect("more than one group is selected");
278            selected_cost = selected_cost.saturating_sub(self.cost(dropped));
279            removed_messages += dropped.len();
280        }
281    }
282}
283
284/// The longest prefix of `content`, as a history note, that fits
285/// `available_tokens` (binary search over its length in characters).
286fn fit_history_note(
287    content: &str,
288    available_tokens: usize,
289    bytes_per_token: usize,
290) -> Option<Message> {
291    let chars: Vec<char> = content.chars().collect();
292    let mut low = 0usize;
293    let mut high = chars.len();
294    let mut best = None;
295    while low <= high {
296        let middle = low + (high - low) / 2;
297        let candidate = Message::HistoryNote {
298            content: chars[..middle].iter().collect(),
299        };
300        if candidate.estimated_tokens(bytes_per_token) <= available_tokens {
301            best = Some(candidate);
302            low = middle.saturating_add(1);
303        } else if middle == 0 {
304            break;
305        } else {
306            high = middle - 1;
307        }
308    }
309    best
310}
311
312#[cfg(test)]
313mod tests;