use async_trait::async_trait;
use super::traits::{AssembledContext, ChatRequest, ContextAssembler, LlmMessage, TaskProfile};
pub struct TokenBudgetAssembler {
max_tokens: usize,
}
impl TokenBudgetAssembler {
pub fn new(max_tokens: usize) -> Self {
Self { max_tokens }
}
pub fn max_tokens(&self) -> usize {
self.max_tokens
}
}
#[async_trait]
impl ContextAssembler for TokenBudgetAssembler {
async fn assemble(&self, request: &ChatRequest, _profile: &TaskProfile) -> AssembledContext {
let messages = &request.messages;
if messages.is_empty() {
return AssembledContext {
messages: vec![],
token_estimate: 0,
truncated: false,
};
}
let total_tokens = estimate_tokens_for_messages(messages);
if total_tokens <= self.max_tokens {
return AssembledContext {
messages: messages.clone(),
token_estimate: total_tokens,
truncated: false,
};
}
let first = &messages[0];
let first_tokens = estimate_tokens(first);
if messages.len() == 1 {
return AssembledContext {
messages: vec![first.clone()],
token_estimate: first_tokens,
truncated: true,
};
}
let last = &messages[messages.len() - 1];
let last_tokens = estimate_tokens(last);
if messages.len() == 2 {
let total = first_tokens + last_tokens;
return AssembledContext {
messages: vec![first.clone(), last.clone()],
token_estimate: total,
truncated: total > self.max_tokens,
};
}
let reserved = first_tokens + last_tokens;
let mut remaining_budget = self.max_tokens.saturating_sub(reserved);
let mut tail_messages: Vec<LlmMessage> = Vec::new();
for msg in messages[1..messages.len() - 1].iter().rev() {
let msg_tokens = estimate_tokens(msg);
if msg_tokens > remaining_budget {
break;
}
remaining_budget -= msg_tokens;
tail_messages.push(msg.clone());
}
tail_messages.reverse();
let mut result = Vec::with_capacity(2 + tail_messages.len());
result.push(first.clone());
result.extend(tail_messages);
result.push(last.clone());
let final_tokens = estimate_tokens_for_messages(&result);
let truncated = result.len() < messages.len();
AssembledContext {
messages: result,
token_estimate: final_tokens,
truncated,
}
}
}
fn estimate_tokens(msg: &LlmMessage) -> usize {
let content_tokens = msg.content.len() / 4;
let role_overhead = 4; content_tokens + role_overhead
}
fn estimate_tokens_for_messages(messages: &[LlmMessage]) -> usize {
messages.iter().map(estimate_tokens).sum()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::traits::TaskType;
fn make_message(role: &str, content: &str) -> LlmMessage {
LlmMessage {
role: role.into(),
content: content.into(),
tool_call_id: None,
tool_calls: None,
}
}
fn make_profile() -> TaskProfile {
TaskProfile {
task_type: TaskType::Chat,
complexity: 0.3,
keywords: vec![],
}
}
fn make_request(messages: Vec<LlmMessage>) -> ChatRequest {
ChatRequest {
messages,
tools: vec![],
model: None,
max_tokens: None,
temperature: None,
auth_context: None,
complexity_boost: 0.0,
}
}
#[tokio::test]
async fn empty_messages() {
let assembler = TokenBudgetAssembler::new(1000);
let req = make_request(vec![]);
let ctx = assembler.assemble(&req, &make_profile()).await;
assert!(ctx.messages.is_empty());
assert_eq!(ctx.token_estimate, 0);
assert!(!ctx.truncated);
}
#[tokio::test]
async fn single_message_fits() {
let assembler = TokenBudgetAssembler::new(1000);
let req = make_request(vec![make_message("system", "You are helpful.")]);
let ctx = assembler.assemble(&req, &make_profile()).await;
assert_eq!(ctx.messages.len(), 1);
assert!(!ctx.truncated);
assert!(ctx.token_estimate > 0);
}
#[tokio::test]
async fn all_messages_fit_no_truncation() {
let assembler = TokenBudgetAssembler::new(10000);
let req = make_request(vec![
make_message("system", "You are helpful."),
make_message("user", "Hello"),
make_message("assistant", "Hi there!"),
make_message("user", "How are you?"),
]);
let ctx = assembler.assemble(&req, &make_profile()).await;
assert_eq!(ctx.messages.len(), 4);
assert!(!ctx.truncated);
}
#[tokio::test]
async fn truncation_preserves_system_message() {
let assembler = TokenBudgetAssembler::new(20);
let req = make_request(vec![
make_message("system", "Be helpful."), make_message("user", "First question here"), make_message("assistant", "First answer here"), make_message("user", "Recent msg"), ]);
let ctx = assembler.assemble(&req, &make_profile()).await;
assert_eq!(ctx.messages[0].role, "system");
assert_eq!(ctx.messages[0].content, "Be helpful.");
assert!(ctx.truncated);
assert!(ctx.messages.len() < 4);
let last = ctx.messages.last().unwrap();
assert_eq!(last.content, "Recent msg");
}
#[tokio::test]
async fn truncation_keeps_most_recent_messages() {
let assembler = TokenBudgetAssembler::new(25);
let req = make_request(vec![
make_message("system", "System."), make_message("user", "old msg one"), make_message("assistant", "old reply"), make_message("user", "recent question"), make_message("assistant", "recent reply"), ]);
let ctx = assembler.assemble(&req, &make_profile()).await;
assert!(ctx.truncated);
assert_eq!(ctx.messages[0].role, "system");
let last = ctx.messages.last().unwrap();
assert_eq!(last.content, "recent reply");
}
#[tokio::test]
async fn single_oversized_message_included() {
let assembler = TokenBudgetAssembler::new(5);
let req = make_request(vec![make_message(
"system",
"This is a very long system prompt that exceeds the budget",
)]);
let ctx = assembler.assemble(&req, &make_profile()).await;
assert_eq!(ctx.messages.len(), 1);
assert!(ctx.truncated);
}
#[test]
fn estimate_tokens_basic() {
let msg = make_message("user", "Hello world"); let tokens = estimate_tokens(&msg);
assert_eq!(tokens, 6);
}
#[test]
fn estimate_tokens_empty_content() {
let msg = make_message("user", ""); let tokens = estimate_tokens(&msg);
assert_eq!(tokens, 4); }
#[test]
fn estimate_tokens_long_content() {
let content = "a".repeat(400); let msg = make_message("user", &content);
let tokens = estimate_tokens(&msg);
assert_eq!(tokens, 104);
}
#[test]
fn estimate_tokens_for_multiple_messages() {
let messages = vec![
make_message("system", "You are helpful."), make_message("user", "Hello"), ];
let total = estimate_tokens_for_messages(&messages);
assert_eq!(total, 13);
}
#[test]
fn max_tokens_accessor() {
let assembler = TokenBudgetAssembler::new(4096);
assert_eq!(assembler.max_tokens(), 4096);
}
#[tokio::test]
async fn exact_budget_fit_no_truncation() {
let msg1 = make_message("system", "ok"); let msg2 = make_message("user", "hi"); let budget = estimate_tokens(&msg1) + estimate_tokens(&msg2);
let assembler = TokenBudgetAssembler::new(budget);
let req = make_request(vec![msg1, msg2]);
let ctx = assembler.assemble(&req, &make_profile()).await;
assert_eq!(ctx.messages.len(), 2);
assert!(!ctx.truncated);
}
#[tokio::test]
async fn profile_is_ignored() {
let assembler = TokenBudgetAssembler::new(10000);
let req = make_request(vec![make_message("user", "test")]);
let profile_a = TaskProfile {
task_type: TaskType::CodeGeneration,
complexity: 0.9,
keywords: vec!["code".into()],
};
let profile_b = TaskProfile {
task_type: TaskType::Chat,
complexity: 0.1,
keywords: vec![],
};
let ctx_a = assembler.assemble(&req, &profile_a).await;
let ctx_b = assembler.assemble(&req, &profile_b).await;
assert_eq!(ctx_a.messages.len(), ctx_b.messages.len());
assert_eq!(ctx_a.token_estimate, ctx_b.token_estimate);
}
}