Skip to main content

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}