use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct Section {
pub name: String,
pub content: String,
pub max_tokens: usize,
pub priority: u8,
}
#[derive(Debug, Clone)]
pub struct PromptTemplate {
pub sections: Vec<Section>,
pub total_budget: usize,
pub separator: String,
}
impl Default for PromptTemplate {
fn default() -> Self {
Self {
sections: Vec::new(),
total_budget: 4096,
separator: "\n\n---\n\n".to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Strategy {
Greedy,
Balanced,
Recency,
}
pub trait TokenCounter: Send + Sync {
fn count_tokens(&self, text: &str) -> usize;
}
pub struct SimpleTokenCounter;
impl TokenCounter for SimpleTokenCounter {
fn count_tokens(&self, text: &str) -> usize {
text.len() / 4
}
}
#[derive(Debug, Clone)]
pub struct MemoryEntry {
pub content: String,
pub created_at: DateTime<Utc>,
}
impl MemoryEntry {
pub fn new(content: impl Into<String>, created_at: DateTime<Utc>) -> Self {
Self {
content: content.into(),
created_at,
}
}
}
pub struct ContextBuilder {
counter: Box<dyn TokenCounter>,
}
impl ContextBuilder {
pub fn new(counter: Box<dyn TokenCounter>) -> Self {
Self { counter }
}
pub fn estimate_tokens(&self, text: &str) -> usize {
self.counter.count_tokens(text)
}
pub fn build(
&self,
template: &PromptTemplate,
memories: &[MemoryEntry],
strategy: Strategy,
) -> String {
let mut sections: Vec<&Section> = template.sections.iter().collect();
sections.sort_by_key(|s| s.priority);
let sorted_memories: Vec<&MemoryEntry> = match strategy {
Strategy::Recency => {
let mut m: Vec<&MemoryEntry> = memories.iter().collect();
m.sort_by(|a, b| b.created_at.cmp(&a.created_at));
m
}
_ => memories.iter().collect(),
};
let allocations = self.compute_allocations(template, §ions, strategy);
let mut rendered: Vec<String> = Vec::new();
let mut total_used = 0usize;
for (idx, section) in sections.iter().enumerate() {
let section_budget =
allocations[idx].min(template.total_budget.saturating_sub(total_used));
let mut section_text = section.content.clone();
for memory in &sorted_memories {
let candidate = if section_text.is_empty() {
memory.content.clone()
} else {
format!("{}\n{}", section_text, memory.content)
};
let candidate_tokens = self.counter.count_tokens(&candidate);
if candidate_tokens <= section_budget {
section_text = candidate;
}
}
if section_text.is_empty() {
continue;
}
let section_tokens = self.counter.count_tokens(§ion_text);
let final_text = if section_tokens > section_budget {
self.truncate_to_budget(§ion_text, section_budget)
} else {
section_text.clone()
};
let separator_tokens = if rendered.is_empty() {
0
} else {
self.counter.count_tokens(&template.separator)
};
let final_tokens = self.counter.count_tokens(&final_text);
if total_used + separator_tokens + final_tokens > template.total_budget {
let remaining = template
.total_budget
.saturating_sub(total_used + separator_tokens);
if remaining == 0 {
break;
}
let truncated = self.truncate_to_budget(&final_text, remaining);
if !truncated.is_empty() {
rendered.push(self.render_section(section, &truncated));
}
break; }
total_used += separator_tokens + final_tokens;
rendered.push(self.render_section(section, &final_text));
}
rendered.join(&template.separator)
}
fn compute_allocations(
&self,
template: &PromptTemplate,
sections: &[&Section],
strategy: Strategy,
) -> Vec<usize> {
match strategy {
Strategy::Balanced => {
let total_weight: usize = sections.iter().map(|s| s.max_tokens).sum();
if total_weight == 0 {
return sections.iter().map(|_| 0).collect();
}
sections
.iter()
.map(|s| {
let ratio = s.max_tokens as f64 / total_weight as f64;
(ratio * template.total_budget as f64).floor() as usize
})
.collect()
}
Strategy::Greedy | Strategy::Recency => sections.iter().map(|s| s.max_tokens).collect(),
}
}
fn truncate_to_budget(&self, text: &str, budget: usize) -> String {
const SUFFIX: &str = "...[truncated]";
let suffix_tokens = self.counter.count_tokens(SUFFIX);
if budget <= suffix_tokens {
return if budget == 0 {
String::new()
} else {
SUFFIX.to_string()
};
}
let char_budget = (budget - suffix_tokens) * 4;
if text.len() <= char_budget {
let tokens = self.counter.count_tokens(text);
if tokens <= budget {
return text.to_string();
}
}
let mut end = char_budget.min(text.len());
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
format!("{}{}", &text[..end], SUFFIX)
}
fn render_section(&self, section: &Section, content: &str) -> String {
format!("## {}\n{}", section.name, content)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn make_counter() -> Box<dyn TokenCounter> {
Box::new(SimpleTokenCounter)
}
fn make_memory(content: &str, days_ago: i64) -> MemoryEntry {
let created_at =
Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap() + chrono::Duration::days(-days_ago);
MemoryEntry::new(content, created_at)
}
fn simple_template(total_budget: usize) -> PromptTemplate {
PromptTemplate {
sections: vec![
Section {
name: "High Priority".into(),
content: String::new(),
max_tokens: total_budget / 2,
priority: 0,
},
Section {
name: "Low Priority".into(),
content: String::new(),
max_tokens: total_budget / 2,
priority: 1,
},
],
total_budget,
separator: "\n\n---\n\n".into(),
}
}
#[test]
fn test_greedy_fills_high_priority_first() {
let builder = ContextBuilder::new(make_counter());
let memories = vec![
make_memory("AAAA AAAA AAAA AAAA", 2), make_memory("BBBB BBBB BBBB BBBB", 3), ];
let template = simple_template(40);
let result = builder.build(&template, &memories, Strategy::Greedy);
let high_pos = result.find("High Priority").unwrap_or(usize::MAX);
let low_pos = result.find("Low Priority").unwrap_or(usize::MAX);
assert!(
high_pos < low_pos,
"High-priority section must come before low-priority"
);
assert!(
result.contains("AAAA"),
"High-priority content must be present"
);
}
#[test]
fn test_balanced_proportional_allocation() {
let builder = ContextBuilder::new(make_counter());
let template = PromptTemplate {
sections: vec![
Section {
name: "Small".into(),
content: String::new(),
max_tokens: 100,
priority: 0,
},
Section {
name: "Large".into(),
content: String::new(),
max_tokens: 300,
priority: 1,
},
],
total_budget: 200,
separator: "\n\n---\n\n".into(),
};
let memories = vec![make_memory(&"X".repeat(140), 1)];
let result = builder.build(&template, &memories, Strategy::Balanced);
assert!(result.contains("Large"), "Large section must be rendered");
assert!(
result.contains(&"X".repeat(140)),
"Memory must fit in the Large section"
);
}
#[test]
fn test_recency_prefers_newer_memories() {
let builder = ContextBuilder::new(make_counter());
let template = PromptTemplate {
sections: vec![Section {
name: "Context".into(),
content: String::new(),
max_tokens: 20, priority: 0,
}],
total_budget: 40,
separator: "\n\n---\n\n".into(),
};
let memories = vec![
make_memory("OLD_MEMORY_CONTENT_HERE_PADDED", 10), make_memory("NEW_MEMORY_CONTENT_HERE_PADDED", 0), ];
let result = builder.build(&template, &memories, Strategy::Recency);
assert!(
result.contains("NEW_MEMORY"),
"Recency strategy must prefer newest memory"
);
}
#[test]
fn test_overflow_truncation() {
let builder = ContextBuilder::new(make_counter());
let template = PromptTemplate {
sections: vec![Section {
name: "Tiny".into(),
content: "A".repeat(80), max_tokens: 5,
priority: 0,
}],
total_budget: 50,
separator: "\n\n---\n\n".into(),
};
let result = builder.build(&template, &[], Strategy::Greedy);
assert!(
result.contains("...[truncated]"),
"Overflowed section must end with ...[truncated]; got: {result}"
);
}
#[test]
fn test_empty_sections_skipped() {
let builder = ContextBuilder::new(make_counter());
let template = PromptTemplate {
sections: vec![
Section {
name: "Present".into(),
content: "I have content.".into(),
max_tokens: 100,
priority: 0,
},
Section {
name: "Empty".into(),
content: String::new(), max_tokens: 100,
priority: 1,
},
],
total_budget: 500,
separator: "\n\n---\n\n".into(),
};
let result = builder.build(&template, &[], Strategy::Greedy);
assert!(
result.contains("Present"),
"Non-empty section must be rendered"
);
assert!(
!result.contains("Empty"),
"Truly empty section must be skipped"
);
}
#[test]
fn test_simple_token_counter_accuracy() {
let counter = SimpleTokenCounter;
assert_eq!(counter.count_tokens(""), 0);
assert_eq!(counter.count_tokens("abcd"), 1);
assert_eq!(counter.count_tokens("abcdefgh"), 2);
assert_eq!(counter.count_tokens(&"a".repeat(100)), 25);
assert_eq!(counter.count_tokens("abcdefghij"), 2);
}
#[test]
fn test_total_budget_respected() {
let builder = ContextBuilder::new(make_counter());
let budget = 100;
let template = PromptTemplate {
sections: vec![
Section {
name: "A".into(),
content: String::new(),
max_tokens: 1000,
priority: 0,
},
Section {
name: "B".into(),
content: String::new(),
max_tokens: 1000,
priority: 1,
},
],
total_budget: budget,
separator: "\n\n---\n\n".into(),
};
let memories: Vec<MemoryEntry> = (0..20)
.map(|i| make_memory(&format!("{:0>40}", i), i as i64))
.collect();
let result = builder.build(&template, &memories, Strategy::Greedy);
let token_count = SimpleTokenCounter.count_tokens(&result);
assert!(
token_count <= budget,
"Output tokens ({token_count}) must not exceed total_budget ({budget})"
);
}
#[test]
fn test_estimate_tokens_delegation() {
let builder = ContextBuilder::new(make_counter());
assert_eq!(
builder.estimate_tokens("hello"),
SimpleTokenCounter.count_tokens("hello")
);
assert_eq!(builder.estimate_tokens(""), 0);
}
}