Conversation Pruner — Rust source
Plan how to fit a long chat history into a context budget — which turns to keep, fold into a summary, or drop, protecting system messages and the current request. 100% client-side.
This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! Conversation Pruner — compute a deterministic pruning plan for a
//! token-budgeted chat history.
//!
//! Language: Rust (edition 2021, standard library only)
//! Source: CosmoDev polyglot showcase port of the Conversation Pruner
//! tool (slug: conversation-pruner).
//! Port of src/lib/conversationPruner.ts (the canonical TypeScript
//! implementation).
//! Tool page: https://dev.cosmolabs.org/tools/conversation-pruner
//! License: display source — part of CosmoDev's polyglot tool pages.
//!
//! Given per-message token counts and a context budget, decide which
//! messages to keep verbatim, which to fold into one running summary, and
//! which to drop outright — protecting system messages, pinned turns, the
//! first turn (the opening user request), and the current (last user)
//! request. Token counts are plain integers; the only float math is the
//! summary cost: fixed framing plus 10% of the folded content, rounded up.
use std::fmt;
/// Summary compression model: fixed framing tokens.
pub const SUMMARY_FIXED_TOKENS: i64 = 60;
/// Summary compression model: share of the folded content's tokens.
pub const SUMMARY_RATIO: f64 = 0.1;
/// A chat participant. Mirrors the TypeScript `ChatRole` union.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
System,
User,
Assistant,
Tool,
}
impl Role {
/// The lowercase wire name used by the TypeScript union.
pub fn as_str(&self) -> &'static str {
match self {
Role::System => "system",
Role::User => "user",
Role::Assistant => "assistant",
Role::Tool => "tool",
}
}
}
/// One message plus its prompt-side token count. Pinned messages are never
/// dropped or summarized (the TypeScript's optional flag defaults to false).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConversationMessage {
pub role: Role,
pub content: String,
/// Token count for this message (prompt-side framing included by caller).
pub tokens: i64,
pub pinned: bool,
}
/// What the plan does with one message.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PruneAction {
Keep,
Summarize,
Drop,
}
impl PruneAction {
/// The lowercase action name used by the TypeScript union.
pub fn as_str(&self) -> &'static str {
match self {
PruneAction::Keep => "keep",
PruneAction::Summarize => "summarize",
PruneAction::Drop => "drop",
}
}
}
/// The plan's verdict on one message.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PruneDecision {
pub index: usize,
pub role: Role,
pub action: PruneAction,
pub tokens: i64,
}
/// The full pruning plan for one conversation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrunePlan {
pub decisions: Vec<PruneDecision>,
pub kept_tokens: i64,
pub summarized_tokens: i64,
pub dropped_tokens: i64,
/// Tokens the summary placeholder itself will cost in the prompt.
pub summary_cost_tokens: i64,
pub projected_tokens: i64,
pub fits_budget: bool,
pub warnings: Vec<String>,
}
/// Validation failures. The TypeScript throws a `RangeError`; Rust returns one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PruneError {
/// `budget_tokens` was negative.
NegativeBudget,
/// Some message carried a negative token count.
NegativeMessageTokens,
}
impl fmt::Display for PruneError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PruneError::NegativeBudget => write!(f, "budgetTokens must be >= 0"),
PruneError::NegativeMessageTokens => write!(f, "message tokens must be >= 0"),
}
}
}
impl std::error::Error for PruneError {}
/// Group an integer with en-US commas: `1234567` → `"1,234,567"`, matching
/// `toLocaleString('en-US')` in the TypeScript.
fn thousands(n: i64) -> String {
let digits = n.unsigned_abs().to_string();
let len = digits.len();
let mut out = String::with_capacity(len + len / 3 + 1);
if n < 0 {
out.push('-');
}
for (i, ch) in digits.chars().enumerate() {
if i > 0 && (len - i) % 3 == 0 {
out.push(',');
}
out.push(ch);
}
out
}
/// Compute the pruning plan for `messages` under `budget_tokens`.
pub fn plan_prune(
messages: &[ConversationMessage],
budget_tokens: i64,
) -> Result<PrunePlan, PruneError> {
let mut warnings: Vec<String> = Vec::new();
if budget_tokens < 0 {
return Err(PruneError::NegativeBudget);
}
if messages.iter().any(|m| m.tokens < 0) {
return Err(PruneError::NegativeMessageTokens);
}
let n = messages.len();
let last_user: Option<usize> = (0..n).rev().find(|&i| messages[i].role == Role::User);
// Untouchable: every system message, pinned messages, the first turn (the
// opening user request that anchors the conversation), and the current
// request (the last user message and everything after it).
let mut protected = vec![false; n];
for (i, m) in messages.iter().enumerate() {
if m.role == Role::System || m.pinned {
protected[i] = true;
}
}
if n > 0 {
protected[0] = true;
}
if let Some(first_turn) = (0..n).find(|&i| messages[i].role != Role::System) {
protected[first_turn] = true;
}
let tail_start = match last_user {
Some(i) => i,
// No user turn: only the final message is "current".
None => n.saturating_sub(1),
};
for p in protected.iter_mut().skip(tail_start) {
*p = true;
}
let protected_tokens: i64 = (0..n)
.filter(|&i| protected[i])
.map(|i| messages[i].tokens)
.sum();
if protected_tokens > budget_tokens {
warnings.push(format!(
"Protected messages alone are {} tokens against a {} budget — raise the budget (or reserve less for the reply) before pruning anything else.",
thousands(protected_tokens),
thousands(budget_tokens)
));
}
// Fill the remaining budget newest-to-oldest through the middle.
let mut actions = vec![PruneAction::Drop; n];
for i in 0..n {
if protected[i] {
actions[i] = PruneAction::Keep;
}
}
let mut used = protected_tokens;
for i in (0..n).rev() {
if actions[i] != PruneAction::Drop {
continue;
}
if used + messages[i].tokens <= budget_tokens {
actions[i] = PruneAction::Keep;
used += messages[i].tokens;
} else {
break; // oldest-unfilled remain drop/summarize candidates, newest first stopped
}
}
// Everything still 'drop' in the middle folds into ONE running summary when
// the compressed form fits where the raw turns did not.
let summarize_idx: Vec<usize> = (0..n)
.filter(|&i| actions[i] == PruneAction::Drop && !protected[i])
.collect();
let summarize_tokens: i64 = summarize_idx.iter().map(|&i| messages[i].tokens).sum();
let attempted_summary_cost = if summarize_idx.is_empty() {
0
} else {
SUMMARY_FIXED_TOKENS + (summarize_tokens as f64 * SUMMARY_RATIO).ceil() as i64
};
// The summary only costs anything when it is actually applied — otherwise
// those turns drop and cost zero.
let mut summary_cost = 0;
if attempted_summary_cost > 0 && used + attempted_summary_cost <= budget_tokens {
for &i in &summarize_idx {
actions[i] = PruneAction::Summarize;
}
summary_cost = attempted_summary_cost;
// Mirrors the TypeScript; `used` is not consulted again afterwards.
#[allow(unused_assignments)]
{
used += summary_cost;
}
} else if attempted_summary_cost > 0 {
warnings.push(format!(
"Even the compressed summary ({} tokens) does not fit the remaining budget — the oldest turns are dropped instead.",
thousands(attempted_summary_cost)
));
}
let decisions: Vec<PruneDecision> = (0..n)
.map(|i| PruneDecision {
index: i,
role: messages[i].role,
action: actions[i],
tokens: messages[i].tokens,
})
.collect();
let mut kept_tokens = 0;
let mut dropped_tokens = 0;
let mut folded_tokens = 0;
for d in &decisions {
match d.action {
PruneAction::Keep => kept_tokens += d.tokens,
PruneAction::Drop => dropped_tokens += d.tokens,
PruneAction::Summarize => folded_tokens += d.tokens,
}
}
Ok(PrunePlan {
decisions,
kept_tokens,
summarized_tokens: folded_tokens,
dropped_tokens,
summary_cost_tokens: summary_cost,
projected_tokens: kept_tokens + summary_cost,
fits_budget: kept_tokens + summary_cost <= budget_tokens,
warnings,
})
}
/// Human-readable one-line summary of a plan.
pub fn describe_prune(plan: &PrunePlan) -> String {
if !plan.fits_budget {
return format!(
"Does not fit: {} tokens projected against the budget.",
thousands(plan.projected_tokens)
);
}
let mut parts = vec![format!("{} kept", thousands(plan.kept_tokens))];
if plan.summarized_tokens > 0 {
parts.push(format!(
"{} folded into a {}-token summary",
thousands(plan.summarized_tokens),
thousands(plan.summary_cost_tokens)
));
}
if plan.dropped_tokens > 0 {
parts.push(format!("{} dropped", thousands(plan.dropped_tokens)));
}
format!("{} — fits the budget.", parts.join(" · "))
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →