use crate::brain::provider::{ContentBlock, Message, Role};
use crate::brain::tokenizer;
use crate::db::models::Message as DbMessage;
use std::path::PathBuf;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct AgentContext {
pub session_id: Uuid,
pub system_brain: Option<String>,
pub messages: Vec<Message>,
pub tracked_files: Vec<TrackedFile>,
pub token_count: usize,
pub max_tokens: usize,
}
#[derive(Debug, Clone)]
pub struct TrackedFile {
pub id: Uuid,
pub path: PathBuf,
pub content: Option<String>,
pub token_count: usize,
}
impl AgentContext {
pub fn new(session_id: Uuid, max_tokens: usize) -> Self {
Self {
session_id,
system_brain: None,
messages: Vec::new(),
tracked_files: Vec::new(),
token_count: 0,
max_tokens,
}
}
pub fn with_system_brain(mut self, prompt: String) -> Self {
self.token_count += Self::estimate_tokens(&prompt);
self.system_brain = Some(prompt);
self
}
pub fn add_message(&mut self, message: Message) {
let tokens = self.estimate_message_tokens(&message);
self.token_count += tokens;
self.messages.push(message);
}
pub fn from_db_messages(
session_id: Uuid,
db_messages: Vec<DbMessage>,
max_tokens: usize,
) -> Self {
let mut context = Self::new(session_id, max_tokens);
for db_msg in db_messages {
let has_content = !db_msg.content.is_empty();
let has_thinking = db_msg
.thinking
.as_deref()
.is_some_and(|t| !t.trim().is_empty());
if !has_content && !has_thinking {
continue;
}
let role = match db_msg.role.as_str() {
"user" => Role::User,
"assistant" => Role::Assistant,
"system" => Role::System,
_ => Role::User, };
let mut content: Vec<ContentBlock> = Vec::new();
if role == Role::Assistant
&& has_thinking
&& let Some(thinking) = db_msg.thinking.as_deref()
{
content.push(ContentBlock::Thinking {
thinking: thinking.to_string(),
signature: None,
});
}
if has_content {
content.push(ContentBlock::Text {
text: db_msg.content,
});
}
let message = Message { role, content };
context.add_message(message);
}
context
}
pub fn track_file(&mut self, file: TrackedFile) {
self.token_count += file.token_count;
self.tracked_files.push(file);
}
pub fn would_exceed_limit(&self, additional_tokens: usize) -> bool {
self.token_count + additional_tokens > self.max_tokens
}
fn estimate_message_tokens(&self, message: &Message) -> usize {
let mut tokens = 0;
for content in &message.content {
match content {
ContentBlock::Text { text } => {
tokens += Self::estimate_tokens(text);
}
ContentBlock::ToolUse { name, input, .. } => {
tokens += Self::estimate_tokens(name);
tokens += Self::estimate_tokens(&input.to_string());
}
ContentBlock::ToolResult { content, .. } => {
tokens += Self::estimate_tokens(content);
}
ContentBlock::Image { .. } => {
tokens += 1000;
}
ContentBlock::Thinking { thinking, .. } => {
tokens += Self::estimate_tokens(thinking);
}
}
}
tokens + 4
}
pub fn estimate_tokens(text: &str) -> usize {
tokenizer::count_tokens(text)
}
pub fn estimate_tokens_static(message: &Message) -> usize {
let mut tokens = 0;
for content in &message.content {
match content {
ContentBlock::Text { text } => {
tokens += Self::estimate_tokens(text);
}
ContentBlock::ToolUse { name, input, .. } => {
tokens += Self::estimate_tokens(name);
tokens += Self::estimate_tokens(&input.to_string());
}
ContentBlock::ToolResult { content, .. } => {
tokens += Self::estimate_tokens(content);
}
ContentBlock::Image { .. } => {
tokens += 1000;
}
ContentBlock::Thinking { thinking, .. } => {
tokens += Self::estimate_tokens(thinking);
}
}
}
tokens + 4
}
pub fn usage_percentage(&self) -> f64 {
(self.token_count as f64 / self.max_tokens as f64) * 100.0
}
fn is_orphaned_tool_result_msg(msg: &Message) -> bool {
msg.role == Role::User
&& !msg.content.is_empty()
&& msg
.content
.iter()
.all(|b| matches!(b, ContentBlock::ToolResult { .. }))
}
fn drop_leading_orphan_tool_results(&mut self) {
while self
.messages
.first()
.is_some_and(Self::is_orphaned_tool_result_msg)
{
let tokens = self.estimate_message_tokens(&self.messages[0]);
self.token_count = self.token_count.saturating_sub(tokens);
self.messages.remove(0);
}
}
pub fn trim_to_fit(&mut self, required_space: usize) {
while self.would_exceed_limit(required_space) && !self.messages.is_empty() {
if let Some(first_msg) = self.messages.first() {
let tokens = self.estimate_message_tokens(first_msg);
self.token_count = self.token_count.saturating_sub(tokens);
self.messages.remove(0);
}
}
self.drop_leading_orphan_tool_results();
}
pub fn hard_truncate_to(&mut self, target_tokens: usize) {
while self.token_count > target_tokens && self.messages.len() > 2 {
let tokens = self.estimate_message_tokens(&self.messages[0]);
self.token_count = self.token_count.saturating_sub(tokens);
self.messages.remove(0);
}
self.drop_leading_orphan_tool_results();
}
pub fn compact_with_summary(&mut self, summary: String, keep_token_budget: usize) {
let summary_tokens = Self::estimate_tokens(&summary) + 50; let available = keep_token_budget.saturating_sub(summary_tokens);
let mut running = 0usize;
let mut keep_count = 0usize;
for msg in self.messages.iter().rev() {
let t = self.estimate_message_tokens(msg);
if running + t > available {
break;
}
running += t;
keep_count += 1;
}
if keep_token_budget > 0 {
keep_count = keep_count.max(2.min(self.messages.len()));
}
let mut keep_start = self.messages.len().saturating_sub(keep_count);
while keep_start < self.messages.len()
&& Self::is_orphaned_tool_result_msg(&self.messages[keep_start])
{
keep_start += 1;
}
let kept_messages: Vec<Message> = self.messages.drain(keep_start..).collect();
self.messages.clear();
let summary_msg = Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: format!(
"[CONTEXT COMPACTION — The conversation was automatically compacted. \
Below is a structured summary of everything before this point.]\n\n{}",
summary
),
}],
};
self.messages.push(summary_msg);
self.messages.extend(kept_messages);
self.token_count = 0;
if let Some(brain) = &self.system_brain {
self.token_count += Self::estimate_tokens(brain);
}
for msg in &self.messages {
self.token_count += self.estimate_message_tokens(msg);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_context_creation() {
let session_id = Uuid::new_v4();
let context = AgentContext::new(session_id, 4096);
assert_eq!(context.session_id, session_id);
assert_eq!(context.max_tokens, 4096);
assert_eq!(context.token_count, 0);
assert!(context.messages.is_empty());
}
#[test]
fn test_add_message() {
let session_id = Uuid::new_v4();
let mut context = AgentContext::new(session_id, 4096);
let message = Message::user("Hello, how are you?");
context.add_message(message);
assert_eq!(context.messages.len(), 1);
assert!(context.token_count > 0);
}
#[test]
fn test_system_brain() {
let session_id = Uuid::new_v4();
let context = AgentContext::new(session_id, 4096)
.with_system_brain("You are a helpful assistant.".to_string());
assert!(context.system_brain.is_some());
assert!(context.token_count > 0);
}
#[test]
fn test_token_estimation() {
let tokens = AgentContext::estimate_tokens("Hello world");
assert!(tokens > 0);
assert!(tokens < 10); }
#[test]
fn test_would_exceed_limit() {
let session_id = Uuid::new_v4();
let mut context = AgentContext::new(session_id, 100);
let message = Message::user("Hello");
context.add_message(message);
assert!(!context.would_exceed_limit(10));
assert!(context.would_exceed_limit(1000));
}
#[test]
fn test_usage_percentage() {
let session_id = Uuid::new_v4();
let mut context = AgentContext::new(session_id, 100);
let long_text = "a".repeat(200); let message = Message::user(long_text);
context.add_message(message);
let usage = context.usage_percentage();
assert!(usage > 0.0 && usage <= 100.0);
}
#[test]
fn test_trim_to_fit() {
let session_id = Uuid::new_v4();
let mut context = AgentContext::new(session_id, 100);
for i in 0..5 {
let long_text = format!(
"This is a longer message {} that will use more tokens to ensure we actually need to trim",
i
);
let message = Message::user(long_text);
context.add_message(message);
}
let original_count = context.messages.len();
context.trim_to_fit(10);
assert!(context.messages.len() < original_count);
}
#[test]
fn test_compact_with_summary_keeps_recent() {
let session_id = Uuid::new_v4();
let mut context = AgentContext::new(session_id, 10000);
for i in 0..10 {
context.add_message(Message::user(format!("Message {}", i)));
}
assert_eq!(context.messages.len(), 10);
let budget = (10000.0 * 0.80) as usize;
context.compact_with_summary("Summary of old messages".to_string(), budget);
if let Some(ContentBlock::Text { text }) = context.messages[0].content.first() {
assert!(text.contains("CONTEXT COMPACTION"));
assert!(text.contains("Summary of old messages"));
} else {
panic!("First message should be a text compaction summary");
}
if let Some(ContentBlock::Text { text }) = context.messages.last().unwrap().content.first()
{
assert!(text.contains("Message 9"));
} else {
panic!("Last message should be Message 9");
}
}
#[test]
fn test_compact_with_summary_recalculates_tokens() {
let session_id = Uuid::new_v4();
let mut context = AgentContext::new(session_id, 10000);
for i in 0..20 {
let big_text = format!("Large message {} {}", i, "x".repeat(400));
context.add_message(Message::user(big_text));
}
let tokens_before = context.token_count;
assert!(tokens_before > 0);
context.compact_with_summary("Brief summary".to_string(), 500);
assert!(context.token_count < tokens_before);
assert!(context.token_count > 0);
}
#[test]
fn test_compact_with_summary_fewer_messages_than_keep() {
let session_id = Uuid::new_v4();
let mut context = AgentContext::new(session_id, 10000);
context.add_message(Message::user("Hello".to_string()));
context.add_message(Message::user("World".to_string()));
context.compact_with_summary("Summary".to_string(), 8000);
assert_eq!(context.messages.len(), 3);
}
#[test]
fn test_compact_with_summary_preserves_system_brain_tokens() {
let session_id = Uuid::new_v4();
let mut context = AgentContext::new(session_id, 10000)
.with_system_brain("You are an AI assistant".to_string());
for i in 0..5 {
context.add_message(Message::user(format!("Msg {}", i)));
}
context.compact_with_summary("Summary".to_string(), 500);
let brain_tokens = AgentContext::estimate_tokens("You are an AI assistant");
assert!(context.token_count >= brain_tokens);
}
#[test]
fn test_compact_empty_context() {
let session_id = Uuid::new_v4();
let mut context = AgentContext::new(session_id, 10000);
context.compact_with_summary("Summary of nothing".to_string(), 8000);
assert_eq!(context.messages.len(), 1);
}
}