orion_core/context.rs
1use crate::error::{CoreError, CoreResult};
2use crate::messages::{Message, Role};
3use crate::template::ChatTemplate;
4use crate::tools::ToolSchema;
5
6/// Strategy for handling context overflow.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum PruneStrategy {
9 /// Drop oldest message pairs (keep system + most recent turns).
10 SlidingWindow,
11 /// Summarize the oldest turns into a single pinned summary message instead
12 /// of dropping them outright. The summarization itself is performed by the
13 /// agent (it needs the LLM backend); the context pipeline still prunes with
14 /// a sliding window once the summary is in place.
15 Summarize,
16}
17
18/// Configuration for context management.
19#[derive(Debug, Clone)]
20pub struct ContextConfig {
21 /// Total context window size in tokens (prompt + reserved response).
22 pub max_context_tokens: u32,
23 /// Tokens reserved for the response; deducted from the prune budget.
24 pub max_response_tokens: u32,
25 /// How to handle a conversation that overflows the budget.
26 pub prune_strategy: PruneStrategy,
27}
28
29impl Default for ContextConfig {
30 fn default() -> Self {
31 Self {
32 max_context_tokens: 4096,
33 max_response_tokens: 2048,
34 prune_strategy: PruneStrategy::SlidingWindow,
35 }
36 }
37}
38
39/// Result of context preparation.
40#[derive(Debug, Clone)]
41pub struct PreparedContext {
42 /// The fully formatted prompt string to feed the backend.
43 pub prompt: String,
44 /// The system prompt with any tool instructions folded in, without template markup.
45 /// What a [`ChatBackend`](crate::ChatBackend) sends as its system message.
46 pub system: String,
47 /// The messages that survived pruning, in order, carrying no system message. What a
48 /// [`ChatBackend`](crate::ChatBackend) sends instead of `prompt`.
49 pub messages: Vec<Message>,
50 /// Total token count of `prompt`.
51 pub token_count: u32,
52 /// Number of conversation messages kept in the prompt.
53 pub messages_included: u32,
54 /// Number of conversation messages dropped to fit the budget.
55 pub messages_pruned: u32,
56}
57
58/// Which turns survive pruning and which are dropped, as index ranges into the
59/// messages slice (in original order). Produced by [`plan_prune`]; the agent
60/// uses `dropped` to decide what to summarize under [`PruneStrategy::Summarize`].
61#[derive(Debug, Clone)]
62pub struct PrunePlan {
63 /// Turns that survive pruning, as index ranges into the messages slice.
64 pub kept: Vec<std::ops::Range<usize>>,
65 /// Turns that are dropped to fit the budget, as index ranges.
66 pub dropped: Vec<std::ops::Range<usize>>,
67}
68
69/// Group conversation messages into turns for pair-wise pruning.
70///
71/// A turn starts with a User message and includes all subsequent non-User
72/// messages (Assistant, ToolCall, ToolResult) until the next User message.
73/// Returns index ranges into the messages slice.
74fn group_into_turns(messages: &[Message]) -> Vec<std::ops::Range<usize>> {
75 let mut turns = Vec::new();
76 let mut turn_start: Option<usize> = None;
77
78 for (i, msg) in messages.iter().enumerate() {
79 if msg.role == Role::User {
80 if let Some(start) = turn_start {
81 turns.push(start..i);
82 }
83 turn_start = Some(i);
84 }
85 }
86 if let Some(start) = turn_start {
87 turns.push(start..messages.len());
88 }
89
90 turns
91}
92
93/// Plan which turns survive pruning to fit the token budget.
94///
95/// 1. Deducts system prompt + tools + assistant-prefix overhead from the budget
96/// 2. Groups messages into turns (user + following non-user messages)
97/// 3. Always keeps the most recent turn and every *pinned* turn
98/// 4. Fills the remaining budget with the most-recent non-pinned turns backward
99///
100/// A turn is pinned if any of its messages is `pinned`. Returns
101/// `CoreError::Context` if the system block, the latest turn, or the pinned
102/// turns alone exceed the available budget.
103pub fn plan_prune(
104 template: &dyn ChatTemplate,
105 system_prompt: &str,
106 messages: &[Message],
107 tools: &[ToolSchema],
108 config: &ContextConfig,
109 token_counter: &dyn Fn(&str) -> u32,
110) -> CoreResult<PrunePlan> {
111 let available = config
112 .max_context_tokens
113 .saturating_sub(config.max_response_tokens);
114
115 // Fixed overhead: system block (system prompt + tools) + assistant prefix.
116 let system_block = template.format_system(system_prompt, tools);
117 let fixed_overhead = token_counter(&system_block) + token_counter(template.assistant_prefix());
118
119 if fixed_overhead >= available {
120 return Err(CoreError::Context(format!(
121 "System prompt and tools ({fixed_overhead} tokens) exceed \
122 available context budget ({available} tokens)"
123 )));
124 }
125 let mut budget = available - fixed_overhead;
126
127 let turns = group_into_turns(messages);
128 if turns.is_empty() {
129 return Ok(PrunePlan {
130 kept: vec![],
131 dropped: vec![],
132 });
133 }
134
135 let turn_costs: Vec<u32> = turns
136 .iter()
137 .map(|range| {
138 messages[range.clone()]
139 .iter()
140 .map(|msg| token_counter(&template.format_message(msg)))
141 .sum()
142 })
143 .collect();
144 let turn_pinned: Vec<bool> = turns
145 .iter()
146 .map(|range| messages[range.clone()].iter().any(|m| m.pinned))
147 .collect();
148
149 let last = turns.len() - 1;
150 let mut keep = vec![false; turns.len()];
151
152 // The latest turn must fit - otherwise context overflow.
153 if turn_costs[last] > budget {
154 return Err(CoreError::Context(format!(
155 "Latest message ({} tokens) plus system prompt \
156 ({fixed_overhead} tokens) exceeds context budget ({available} tokens). \
157 Clear the conversation or increase context size.",
158 turn_costs[last]
159 )));
160 }
161 budget -= turn_costs[last];
162 keep[last] = true;
163
164 // Pinned turns always survive, regardless of recency.
165 for i in 0..last {
166 if turn_pinned[i] {
167 if turn_costs[i] > budget {
168 let pinned_total: u32 = (0..turns.len())
169 .filter(|&j| turn_pinned[j])
170 .map(|j| turn_costs[j])
171 .sum();
172 return Err(CoreError::Context(format!(
173 "Pinned messages ({pinned_total} tokens) exceed the available \
174 context budget ({available} tokens). Unpin some messages or \
175 increase context size."
176 )));
177 }
178 budget -= turn_costs[i];
179 keep[i] = true;
180 }
181 }
182
183 // Fill the remaining budget with the most-recent non-pinned turns, walking
184 // backward. Stop at the first non-pinned turn that doesn't fit (sliding
185 // window); already-pinned turns are skipped without stopping the walk.
186 for i in (0..last).rev() {
187 if keep[i] {
188 continue;
189 }
190 if turn_costs[i] <= budget {
191 budget -= turn_costs[i];
192 keep[i] = true;
193 } else {
194 break;
195 }
196 }
197
198 let mut kept = Vec::new();
199 let mut dropped = Vec::new();
200 for (i, range) in turns.iter().enumerate() {
201 if keep[i] {
202 kept.push(range.clone());
203 } else {
204 dropped.push(range.clone());
205 }
206 }
207 Ok(PrunePlan { kept, dropped })
208}
209
210/// Prepare context: prune to fit the budget, apply the template, return the
211/// formatted prompt. Thin wrapper over [`plan_prune`] that formats the kept
212/// turns. Pinned messages always survive (see `plan_prune`).
213///
214/// The agent calls this automatically before each LLM call; call it directly
215/// only when you want custom control.
216///
217/// ```
218/// use orion_core::{ChatMLTemplate, Message};
219/// use orion_core::context::{prepare_context, ContextConfig};
220///
221/// // A real backend tokenizes; here we approximate with a word count.
222/// let token_counter = |text: &str| -> u32 { text.split_whitespace().count() as u32 };
223/// let messages = vec![
224/// Message::user("1", "Hello"),
225/// Message::assistant("2", "Hi there!"),
226/// ];
227///
228/// let prepared = prepare_context(
229/// &ChatMLTemplate, // any `ChatTemplate` impl
230/// "You are helpful.", // system prompt
231/// &messages, // full conversation history
232/// &[], // tool schemas to inject (may be empty)
233/// &ContextConfig::default(),
234/// &token_counter,
235/// )?;
236///
237/// assert!(prepared.prompt.contains("Hi there!"));
238/// assert_eq!(prepared.messages_included, 2);
239/// assert_eq!(prepared.messages_pruned, 0);
240/// # Ok::<(), orion_core::CoreError>(())
241/// ```
242pub fn prepare_context(
243 template: &dyn ChatTemplate,
244 system_prompt: &str,
245 messages: &[Message],
246 tools: &[ToolSchema],
247 config: &ContextConfig,
248 token_counter: &dyn Fn(&str) -> u32,
249) -> CoreResult<PreparedContext> {
250 let plan = plan_prune(
251 template,
252 system_prompt,
253 messages,
254 tools,
255 config,
256 token_counter,
257 )?;
258
259 // Collect kept messages in original order (kept ranges may be non-contiguous
260 // when an old pinned turn survives alongside the recent window).
261 let kept: Vec<Message> = plan
262 .kept
263 .iter()
264 .flat_map(|range| messages[range.clone()].iter().cloned())
265 .collect();
266 let kept_count = kept.len() as u32;
267 let pruned = messages.len() as u32 - kept_count;
268
269 let prompt = template.format(system_prompt, &kept, tools);
270 let token_count = token_counter(&prompt);
271 let system = format!("{system_prompt}{}", crate::template::render_tools(tools));
272
273 Ok(PreparedContext {
274 prompt,
275 system,
276 messages: kept,
277 token_count,
278 messages_included: kept_count,
279 messages_pruned: pruned,
280 })
281}